生活很不容易。本文原本是两个月前打算发的。但是最后换到今天才发放了。这是因为生活真的很艰难,这个冬天是我最冷的一个冬天,在北方没有暖气着实难捱,前几次发文基本应该都有感叹手脚都不听使唤,穿多少都无法暖和,小太阳其实跟不存在没多大区别,它无法让一个区间暖和起来,在快递点学到一招,那人放倒一个电视大小的纸箱,尾部放一个小太阳在里边,纸箱覆盖一些说不出来是棉还是什么的东西,然后脚和小腿都能伸进去,人坐在纸箱开口这边,腿上搭着像小毛毯似的物品,感觉上挺暖和的。可惜照搬不能,因为找不到合适的材料。
但是还不是熬过来了。
多年前也曾在北方呆过,房檐挂冰锥下来那种。但那一回没什么惧怕的,因为呆的是变电所,不但每个办公室每个房间暖气充足,食堂也热气腾腾,一碗即时的大妈亲手拉面,浇上浇头,再一个盘子一块巨大的排骨,肉奇多的那种,旁边是北方专属的蒜头,稀里呼噜的下去,根本不知道冷为何物。
但这个冬天不一样。
告诉你,新买的 5 斤冬被根本不够,还要盖上一床睡袋,下面用水暖电热毯,才能在没有暖气的北方过下来。外边呆不住也不怕,完全蹲被窝就是最后一招了。
冬天快结束时,又学到了新招,在屋子里面可以买小暖房封锁一小块区域,几个平方的那种,然后里边密封好再生小太阳,过一阵子就能让这块小房间来到个十几度甚至二十度。这方法我还要去了解下,不晓得麻烦不。
另外,几天前又收到了草韵辨体,而且有收到两个版本。现代的人真是幸福,我童年的时候这些古本听都没听说过,那时候,知道孙过庭书谱,手上有赵孟頫真草千字文的,数不出几个人来,大多数人顶多知道神策军碑——我倒不是在说柳公权不行,而是想说真正的孤本善本你连听说的门路都没有,哪里像现在这么丰富,唾手可得。
我学习编程的年代,哪里有什么学习材料。
所以才会说现在的10~20岁的人多么幸福啊,他们不能做出发明创造的话,对得起这么好的环境吗?哈哈,胡乱地说,胡乱地唱。不要当真。
在 C++17 及以前的规范中,并发与同步依靠的是 std::mutex 和 std::condition_variable 的组合体。
在操作系统中同步与互斥还会涉及到 critical section 和 semaphore,前者是 std::mutex 的另一种表现,后者需要使用 std::condition_variable 来达成。当然在 C++20 中提供了 std::counting_semaphore 和 std::binary_semaphore,这就是另一个话题了。下次再聊。
这两个工具类出自于 C++20 之后。
它们的作用是建立内存屏障,以便多任务能够同步到一个公共时间点。
具体地说,std::latch 基本上等同于 Golang 中的 sync.WaitGroup,它持有一个计数器,你应该给定一个初值,例如线程数量,然后递减计数器,当计数归零时则在同步点的阻塞就被释放。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <functional>
#include <iostream>
#include <latch>
#include <string>
#include <thread>
struct Job
{
const std::string name;
std::string product{"not worked"};
std::thread action{};
};
int main()
{
Job jobs[]{ {"Annika"}, {"Buru"}, {"Chuck"} };
std::latch work_done{std::size(jobs)};
std::latch start_clean_up{1};
auto work = [&](Job& my_job)
{
my_job.product = my_job.name + " worked";
work_done.count_down();
start_clean_up.wait();
my_job.product = my_job.name + " cleaned";
};
std::cout << "Work is starting... ";
for (auto& job : jobs)
job.action = std::thread{work, std::ref(job)};
work_done.wait();
std::cout << "done:\n";
for (auto const& job : jobs)
std::cout << " " << job.product << '\n';
std::cout << "Workers are cleaning up... ";
start_clean_up.count_down();
for (auto& job : jobs)
job.action.join();
std::cout << "done:\n";
for (auto const& job : jobs)
std::cout << " " << job.product << '\n';
}
在示例代码中,job 线程的 body 通过 work_done.count_down()
来递减计数器,而主线程是在 work_done.wait()
处阻塞,直到所有 jobs 都递减了计数器值,则计数归零,则该阻塞的同步点释放,主线程才会继续向下执行。
注意除了 wait() 之外,你还可以使用 work_done.arrive_and_wait(),这个接口将递减计数器 count_down() 与 wait() 合二为一了,取决于你的业务逻辑有时候可以直接使用这个接口来简化代码结构。
Golang 的 WaitGroup
有相同的表现,只不过它通过 wg.Add(n)
来设定计数器初值,同样地递减计数器(via wg.Done()
)直到归零时释放阻塞的同步点,效果没有任何区别。示例代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int) {
fmt.Printf("Worker %d starting\n", id)
time.Sleep(time.Second)
fmt.Printf("Worker %d done\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1) // increase the internal counter
go func() {
defer wg.Done()
worker(i)
}()
}
wg.Wait() // the sync point here
}
在 C++20 之前,没有 std::latch
怎么办呢?std::latch 其实只不过是一个语法糖,它是条件变量(condition_variable)的一种包装后的形式,实质上没有区别。我们知道条件变量一般是和 mutex(或者其他 lockable)一起工作的,假设你为条件变量设定一个初值,然后递减之,通过 wait_for/wait_until 就能够在计数器归零时触发动作。例如上面的示例代码可以用条件变量来改写:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <string>
#include <thread>
std::mutex m;
std::condition_variable cv;
std::string data;
bool ready = false;
bool processed = false;
void worker_thread()
{
// Wait until main() sends data
std::unique_lock lk(m);
cv.wait(lk, []{ return ready; });
// after the wait, we own the lock.
std::cout << "Worker thread is processing data\n";
data += " after processing";
// Send data back to main()
processed = true;
std::cout << "Worker thread signals data processing completed\n";
// Manual unlocking is done before notifying, to avoid waking up
// the waiting thread only to block again (see notify_one for details)
lk.unlock();
cv.notify_one();
}
int main()
{
std::thread worker(worker_thread);
data = "Example data";
// send data to the worker thread
{
std::lock_guard lk(m);
ready = true;
std::cout << "main() signals data ready for processing\n";
}
cv.notify_one();
// wait for the worker
{
std::unique_lock lk(m);
cv.wait(lk, []{ return processed; });
}
std::cout << "Back in main(), data = " << data << '\n';
worker.join();
}
虽然不是一摸一样的重写,但意图是相同的。
而且采用条件变量能够获得更多的灵活性,例如当条件触发时,还可以执行一个预定义动作。在这里的示例中,这个预定义动作是一个 lambda 函数:
1
[]{ return processed; }
说到这里,那就要提及 std::barrier 了。
std::barrier 和 std::latch 是一样的,只是多了能够指定触发事件的能力。
所以,可以继续示例如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <barrier>
#include <iostream>
#include <string>
#include <syncstream>
#include <thread>
#include <vector>
int main()
{
const auto workers = {"Anil", "Busara", "Carl"};
auto on_completion = []() noexcept
{
// locking not needed here
static auto phase =
"... done\n"
"Cleaning up...\n";
std::cout << phase;
phase = "... done\n";
};
std::barrier sync_point(std::ssize(workers), on_completion);
auto work = [&](std::string name)
{
std::string product = " " + name + " worked\n";
std::osyncstream(std::cout) << product; // ok, op<< call is atomic
sync_point.arrive_and_wait();
product = " " + name + " cleaned\n";
std::osyncstream(std::cout) << product;
sync_point.arrive_and_wait();
};
std::cout << "Starting...\n";
std::vector<std::jthread> threads;
threads.reserve(std::size(workers));
for (auto const& worker : workers)
threads.emplace_back(work, worker);
}
它的输出可能形如这样:
1
2
3
4
5
6
7
8
9
10
Starting...
Anil worked
Carl worked
Busara worked
... done
Cleaning up...
Busara cleaned
Carl cleaned
Anil cleaned
... done
当然,std::barrier 还是多遍的。这个多遍的意思是指,它允许在一次同步之后再次设定新的同步点,此时所有线程会在新的位置阻塞,计数器也被复原,然后重复递减直到再次归零。在上面的例子里,第一个 sync_point.arrive_and_wait()
意味着每个线程在该位置递减计数器并阻塞,直到全部线程都 waked up 并执行到该点的时候,递减足够了,计数器归零了,所有线程才同时全部从该点的阻塞状态中释放并继续执行。此时,sync_point 的计数器也恢复初值,于是每个线程可以在第二个 sync_point.arrive_and_wait()
同步点重复上述过程,这就是第二遍的同步点。如是反复,你可以使用 sts::barrier 在多线程中制作 n 个同步点。
这有何作用呢?
对于分阶段的计算密集工作池来说,这可能是有用的。设想一个工作池中不断调度计算任务。每个计算任务首先载入输入数据的某一个分片,全部计算任务将会分担输入数据的所有分片,当分片全部载入成功时——这是第一个同步点——每个计算任务都进入计算过程,这就是第二个同步点,直到所有计算任务完成计算之后,它们都进入第三个阶段,将计算结果写出到输出数据区中,同样地所有计算结果分片写出完成后,第四个阶段是后处理过程,将所有计算结果分片混合和组织为单一汇总的计算结果。
这时候,std::barrier 毫无疑问就是最佳选择了。
同样地道理,std::barrier 也是条件变量的一种语法糖。没有它,例如在 C++17 及以前同样也能很好滴生活。
好,介绍一下我们的 hicc::pool::conditional_wait,它也有正式版本在 cmdr-cxx 库中,叫做 cmdr::pool::conditional_wait。我一直以来都是在 hicc 或者 design patterns-cxx 中试验这些工具,然后再考虑将稳定的版本移植到 cmdr-cxx 或者 undo-cxx, ticker-cxx 等等稳定的开源库中的。
广告结束,conditional_wait 是一个 std::condition_variable 的包装,旨在让你能够以更好的语义书写业务逻辑。
例如同样的等待全部线程到达一个同步点,可以写作:
1
2
3
4
5
6
7
8
9
10
11
conditional_wait_for_int _cv_started{};
// run all theads
std::async(std::launch::async, []{
_cv_started.set();
// ok, here all threads are alive.
});
// and wait for all of them are alive
_cv_started.wait();
那么,conditional_wait 的实现代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
// conditional_wait, ...
namespace hicc::pool {
/**
* @brief a wrapper class for using std condition variable concisely
* @tparam T any type holder
* @tparam Pred a functor with prototype `bool()`
* @tparam Setter a functor with prototype `void()`
* @see hicc::pool::conditional_wait_for_bool
* @see hicc::pool::conditional_wait_for_int
*/
template<typename T, typename Pred = std::function<bool()>, typename Setter = std::function<void()>>
class conditional_wait {
Pred _p{};
Setter _s{};
protected:
std::condition_variable _cv{};
std::mutex _m{};
T _var{};
public:
explicit conditional_wait(Pred &&p_, Setter &&s_)
: _p(std::move(p_))
, _s(std::move(s_)) {}
virtual ~conditional_wait() { clear(); }
// conditional_wait(conditional_wait &&) = delete;
// conditional_wait &operator=(conditional_wait &&) = delete;
CLAZZ_NON_COPYABLE(conditional_wait);
public:
/**
* @brief wait for Pred condition matched
*/
void wait() {
std::unique_lock<std::mutex> lk(_m);
_cv.wait(lk, _p);
}
const bool ConditionMatched = true;
/**
* @brief wait for Pred condition matched, or a timeout arrived.
* @tparam R _Rep
* @tparam P _Period
* @param rel_time a timeout (std::chrono::duration)
* @return true if condition matched, false while not matched.
* >> false if the predicate pred still evaluates to false after
* the rel_time timeout expired, otherwise true.
*
* @details blocks the current thread until the condition
* variable is woken up or after the specified timeout duration.
*/
template<class R, class P>
bool wait_for(std::chrono::duration<R, P> const &rel_time) {
std::unique_lock<std::mutex> lk(_m);
return _cv.wait_for(lk, rel_time, _p);
}
bool wait_for() { return wait_for(std::chrono::hours::max()); }
/**
* @brief wait_until causes the current thread to block until the
* condition variable is notified, a specific time is reached,
* or a spurious wakeup occurs, optionally looping until some
* predicate is satisfied.
* @tparam C Clock
* @tparam D Duration
* @param timeout_time
* @return false if the predicate pred still evaluates to false
* after the timeout_time timeout expired, otherwise true. If
* the timeout had already expired, evaluates and returns the
* result of pred.
*/
template<class C, class D>
bool wait_until(std::chrono::time_point<C, D> const &timeout_time) {
std::unique_lock<std::mutex> lk(_m);
return _cv.wait_until(lk, timeout_time, _p);
}
bool wait_until() { return wait_until(std::chrono::time_point<std::chrono::system_clock>::max()); }
/**
* @brief do Setter, and trigger any one of the wating routines
*/
void set() {
// dbg_debug("%s", __FUNCTION_NAME__);
{
std::unique_lock<std::mutex> lk(_m);
_s();
}
_cv.notify_one();
}
/**
* @brief do Setter, trigger and wake up all waiting routines
*/
void set_for_all() {
// dbg_debug("%s", __FUNCTION_NAME__);
{
std::unique_lock<std::mutex> lk(_m);
_s();
}
_cv.notify_all();
}
void clear() { _release(); }
T const &val() const { return _value(); }
T &val() { return _value(); }
protected:
virtual T const &_value() const { return _var; }
virtual T &_value() { return _var; }
virtual void _release() {}
};
template<typename CW>
class cw_setter {
public:
cw_setter(CW &cw)
: _cw(cw) {}
~cw_setter() { _cw.set(); }
private:
CW &_cw;
};
class conditional_wait_for_bool : public conditional_wait<bool> {
public:
conditional_wait_for_bool()
: conditional_wait([this]() { return _wait(); }, [this]() { _set(); }) {}
virtual ~conditional_wait_for_bool() = default;
conditional_wait_for_bool(conditional_wait_for_bool &&) = delete;
conditional_wait_for_bool &operator=(conditional_wait_for_bool &&) = delete;
protected:
bool _wait() const { return _var; }
void _set() { _var = true; }
public:
void kill() { set_for_all(); }
};
class conditional_wait_for_int : public conditional_wait<int> {
public:
conditional_wait_for_int(int max_value_ = 1)
: conditional_wait([this]() { return _wait(); }, [this]() { _set(); })
, _max_value(max_value_) {}
virtual ~conditional_wait_for_int() = default;
conditional_wait_for_int(conditional_wait_for_int &&) = delete;
conditional_wait_for_int &operator=(conditional_wait_for_int &&) = delete;
inline int max_val() const { return _max_value; }
protected:
inline bool _wait() const { return _var >= _max_value; }
inline void _set() { _var++; }
private:
int _max_value;
};
}
两个简化形式 conditional_wait_for_bool 和 conditional_wait_for_int 是正常编码时推荐使用的工具类。
conditional_wait 是基于 C++17 的,所以通用性略强于 std::barrier 和 std::latch。它目前的唯一缺点是缺乏 std::barrier 的多遍同步点能力,好在这个能力的适用场景也往往很专一和狭窄,所以也许也算不得什么缺点。
在这篇旧文里主要介绍了为 hicc 和 cmdr 设计的专属线程池,它具有固定大小,提前建立工作线程,等待用户调度工作任务到池中,属于像数据库连接池、或者工作任务线程池这样的概念。
也可以设计和实现可变大小的,直到用户提交任务时才调度一个 OS 线程运行的古典线程池。也可以设计实现采用协程的协程池,当然要么自行实现协程库、要么采用 C++20。至于通用编程概念中的 WorkerPool,ResourcePool,ConnectPool,TaskPool/JobPool,Scheduler,Executor 等等,也只是万变不离其宗而已。
同样道理,可以将其改写为使用 std::barrier 方式,没有难度,略过。
放飞自我时间到!
哦对了,今次开了一回引子所以尾巴上就不放飞了。
🔚
]]>git log
exit通常 git log 和 git diff 命令返回之后它们的屏幕输出会被清除,这一特性在 mac 和 linux 中比较常见,这是因为默认时它们的输出被隐含地管道输出给 less 命令接管。
less 命令本身支持标准输入的接管和全屏幕显示,然后在 less 退出时自动清屏以退出全屏显示,这是 linux Terminal/Tty 的一种不成文的规定,原因可以追溯到古早时期,清屏的目的是为了将终端显示屏幕给弄干净啰,不然的话光标说不定在屏幕正中,隐藏在一片文字之中,难免让人无法找到其坐标。为此当时人们的基操是退出全屏 CLI 程序后就回车多次,然后扫视屏幕变化从而找到光标位置。于是乎后来就干脆让全屏 CLI 程序总是在运行开始时请求全屏幕控制,具体参考 ANSI Color Sequences 规范,然后在运行结束前除了归还全屏控制之外也自动清屏。
less 命令已经 man 命令我以前曾经专门介绍过它们的使用特色,请阅读:
所以,让 less 在退出时保留屏幕输出的办法是增加命令行参数 -X
,See Here,
而让 git 自动引用这一变动的方法是:
1
git config --global --replace-all core.pager "less -iRFX"
其中的参数作用为:
^X
样式然后你就可以愉快地玩耍了。
由于 git log
和 git diff
都会使用操作系统环境变量 PAGER 的参数,所以上述方法同时作用于这些命令。而且,如果你想要临时变更而不是永久变更的话,也可以这样:
1
2
$ PAGER='less -iRFX' git log
$ PAGER='less -iRFX' git diff
delta
为了让 git diff 在命令行中的输出更有参考性,你可能已经定制了 git config core.pager
去使用 delta
这个第三方工具。
delta
是一个 less 的替代品,它用在 git log/diff/blame 场景中的主要亮点在于:
等等。
好,我不是要带货推广,delta 的使用很容易,直接去 官网 阅读就好。
这里只给出一个快捷方式,为了保留屏幕输出,你可以给 delta 加上 –pager 参数。是不是很熟悉?这样就可以沿用前文所给出的 less 解决方案了。所以当你使用 delta 作为 git 的 pager 时,就这么定制一下:
1
git config --global --replace-all core.pager "delta --dark -n --navigate --pager 'less -iRFX'"
然后就继续愉快地玩耍吧。
一个效果图如下:
很多事情都很奇妙。
这次只是一个简单的记录,因为确实发现记忆力减退了。
🔚
]]>基本上你只能采用单个的巨大无比的 monorepo 才能解决各种潜在可能的麻烦。
如果你曾经尝试过多种方案的嵌套、平级的 go.mod 组织,你才会知道我在说什么。为了解决这时候的各种问题,特别是当你有时候暂时不会发布 repo 到 github,或者你在使用私有 repo,或者公司域名,那么常常会在这些地方心力交瘁。
但麻烦不仅如此。当你在发布一个公共库的时候,按道理你应该有大量的 testcases,examples 附着在 repo 中,以便提供和证明这个公共库已经经过了严格的自我证明。但这就会在 repo 中引入意料之外的依赖关系。为此,我在发布公共库的时候根本不使用 testify,是我如此自矜吗。并非如此,我只是为了让依赖关系干净一点,特别是在一些时间节点之后大家都很关注代码的追根溯源、潜在的不合法的许可证依赖关系,此时引用第三方库几乎成了约定俗成的不可为的第三件事了。
嗯,第三件?Waht’s first and second?我胡诌的。
解决的办法总是会有。比如建立第二个 repo,专为前一个库提供 examples,那就可以随便胡搞了。但是 tests 呢,没法,因为有时候 tests 需要在包内,不可能单开一个新的 repo。所以我基本上不使用 testify,实在忍不住时就临时手搓几个类似的 assertions 来用一用。
但是通过嵌入一个 go.work 之后,examples 和 tests 就可以在同一个 repo 中以子目录的方式自行独立建立 go.mod 了。
这一方法的结构基本上是这么构成的。假设你有一个公共库 go-socketlib,它是一个 repo,其根目录和子目录结构框架如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ tree .
.
├── _examples
│ ├── colored_slog.go
│ ├── go.mod
│ ├── go.sum
... ...
├── addons
│ └── cmdr
│ ├── README.md
│ ├── cmdr.go
│ ├── go.mod
│ └── go.sum
...
├── doc.go
├── go-socketlib.code-workspace
├── go.mod
├── go.sum
├── go.work
├── go.work.sum
...
而根下面的 go.work 包含这样的内容:
go 1.21
use (
.
./addons/cmdr
./_examples
)
就是这样。
用户在使用 go-socketlib 的时候,不会受到这个 go.work 的干扰,因为在这个 go.work 完全只负责自己的目录,不会溢出到目录结构之外。
由于该 go.work 的存在,在 examples 中引入的第三方库依赖关系不会再体现在 go-socketlib 的本体中,所以用户得到了一个干净的库引用,不必被 examples 所污染。
这就是我们想要的效果。
看起来这是一个完美的解决方案。它只有一个问题,如果你的库在初期开发中,没有建立 github 上的 repo,更没有做一次推送,那么纯本地环境中你还是会遇到问题。尤其是当你在建立一整套系统架构时,不但有多个 apps,还有多个公共库,那么问题也会很复杂。
此时,go.mod 中的 replace 子句将是你的救星。尽管看起来龌蹉,但管用。一旦你进行了推送,就可以依次解除这些 replace 子句。当你正式发布全套架构后,应该摘除全部 replace 子句,以防止它们破坏 pullers 的构建环境,导致使用者下拉代码后无法构建。
早前(几年来)我一直设法达成本文想要达到的目标,想要无污染地形成潜逃 go.mod,为此做出过种种尝试,无一例外,都算是失败,除了 go 本身的支持方案缺失之外,各个 IDEs 的限制也是问题。曾经留下的记录在这个博客中也有记录,包括
… 嵌套 go.mod 有可能吗 …
等等,但到今天的方案为止,那些旧文虽不算错,但是确实过时了。目前 Goland 和 vscode 中本文的新的结构是可以工作的。
本文的要点在于,你要保证一个自包含的 root directory 之下,go.work 只负责自身及其子目录,那么所有问题就被协调了。
hedzr/go-socketlib 是我首次践行这一方案的样本,实践的效果大体上还是满意的。你可以阅读源码相关部分,理解我所说的这种内嵌 go.work 管理嵌套 go.mod 的方案。
🔚
]]>使用 CMake Presets
按:
一直需要 CMake 具有自动 import 某个脚本的能力。但它并没有。
最为接近的能力有两个:
其中 CMAKE_TOOLCHAIN_FILE 顾名思义是早期辅助设定 toolchain 用的(一般是用于交叉编译场景,vcpkg 也采用了该机制),这意味着它的加载时机和作用时机都非常早,早到不适合给针对具体项目进行必要的初始化设定。同时它的问题是它是破坏性的,你必须明确地在命令行中指定要加载的脚本,这不是自动的。所以它不行。
尽管像 vscode 等允许你提前设置一个文件,这样就不必显式带上命令行参数了,但你只能指定一个!如果你同时使用 vcpkg 而且自定义了一个脚本,怎么办呢?没有办法,我猜你的选择是放弃 vcpkg,是吗?
我不是,我两个都放弃。vcpkg 这个引用 –toolchain 的用法我一点也不喜欢。其实应该怪的是 CMake 真的很垃圾,各种的奇形怪状的设定。
至于 CMake Presets 嘛。
嗯,一言难尽!
它不是我原本希望想要的那个东西。它也需要命令行参数干预,只不过现代编辑器将其整合到构建类型选择框中了。但它的确有点用。本文试图将其用法展现出来……
编写 CMake 脚本的过程中,我们不断重复着自己:我们一遍又一遍地为一个 executable 或者一个 library 编写着相似甚至于完全相同的设定,尤其是 CMAKE_CXX_FLAGS 这样的东西。
所以我们渴求某种机制能够解决这样的烦恼。
比较容易想到的方法是 macro 和 function。它们可以把某些固定的序列抽出来固化,然后用不同的名字调用就可以了。一种可能的范本大致是像这样子的:
1
2
3
4
5
6
7
8
9
10
11
12
13
macro(new_executable taget_name)
# ... extract target_name and rest args by ARGN
add_executable(${target_name} ${ne_ARG_SOURCES})
add_cxx_standard_to(${target_name} 20)
target_compile_options(${target_name} PRIVATE
-pedantic -Wall -Wextra -Wshadow -Werror -pthread
-Wdeprecated-declarations
)
# ...
endmacro()
new_executable(test_simple simple.cc)
new_executable(test_decimal decimal.cc)
如上所示,现在定义一个可执行文件的 target 就简单多了。
它的问题在于如果对于 test_simple 需要同时应用几套配置,例如 clang-debug,gcc-debug,那么 macro 和 function 都不是合适的手段。
CMake 本身提供了 Debug,Release 这样的 CMAKE_BUILD_TYPE,但还有一点僵硬。因为使用不同的 Build Type 时你仍然需要在脚本中进行判断,并应用不同的编译选项。就如同这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
if(("${CMAKE_BUILD_TYPE}" STREQUAL "Debug") AND(NOT(${WIN32})))
# In non-win32 debug build, debug_malloc is on by default
option(USE_DEBUG_MALLOC "Building with memory leak detection capability." ON)
option(USE_DEBUG "Building with DEBUG Mode" ON)
set(CMAKE_BUILD_NAME "dbg" CACHE STRING "" FORCE)
else()
# In win32 or non-debug builds, debug_malloc is off by default
option(USE_DEBUG_MALLOC "Building with memory leak detection capability." OFF)
option(USE_DEBUG "Building with NON-DEBUG Mode" OFF)
if("${CMAKE_BUILD_TYPE}" STREQUAL "Debug")
set(CMAKE_BUILD_NAME "dbg" CACHE STRING "" FORCE)
set(CMAKE_DEBUG_POSTFIX "d" CACHE STRING "" FORCE)
elseif("${CMAKE_BUILD_TYPE}" STREQUAL "Release")
set(CMAKE_BUILD_NAME "rel" CACHE STRING "release mode" FORCE)
set(CMAKE_RELEASE_POSTFIX "" CACHE STRING "" FORCE)
elseif("${CMAKE_BUILD_TYPE}" STREQUAL "MinSizeRel")
set(CMAKE_BUILD_NAME "rms" CACHE STRING "min-size release mode" FORCE)
set(CMAKE_MINSIZEREL_POSTFIX "ms" CACHE STRING "" FORCE)
elseif("${CMAKE_BUILD_TYPE}" STREQUAL "RelWithDebInfo")
set(CMAKE_BUILD_NAME "rwd" CACHE STRING "release mode with debug info" FORCE)
set(CMAKE_RELWITHDEBINFO_POSTFIX "" CACHE STRING "" FORCE)
elseif("${CMAKE_BUILD_TYPE}" STREQUAL "Asan")
set(CMAKE_BUILD_NAME "asan" CACHE STRING "debug mode with sanitizer" FORCE)
set(CMAKE_ASAN_POSTFIX "" CACHE STRING "" FORCE)
endif()
endif()
在上面的分支中,你还可以加上 CMAKE_CXX_FLAGS 的设置,例如给 Debug 加上 -g -O0
,给 Release 加上 -O3
,等等。这些都是题中应有之义。但是考虑到不同的编译器:clang, gcc, llvm, msvc, mccv clang-cl, …,这样的分支也并不是那么容易编写。即使你终于写定了,也难以维护。即使你努力维护了,下一个项目可能需求根本不同,这些编译选项还得重新设计编写一套。
这肯定也是不舒适的。
如此,就要提到 CMake 还有一种机制可以处理这样的情形:Presets。
CMake Presets 是这样一种特性,每个 Preset 包含一套预设的选项:
通常大多数 Presets 首先聚焦在 configurePreset 这个部分,因为这里涉及到各种构建环境前提条件的控制,例如 build-host 的处理器架构,关联到的 CMAKE_BUILD_TYPE 等等。
CMake 将会在 root CMakeLists.txt 这一级寻找 CMakePresets.json
或 CMakeUserPresets.json
文件,加载其中定义的各种预设值形成一套构建配置 Matrix,并以这个 Matrix 替代原始的 CMAKE_BUILD_TYPE 方案,从而提供更强的配置能力。原始的 CMAKE_BUILD_TYPE 毕竟只有 4 种标准类型,虽然也能扩充你的类型,但那也只能构成单级选择,无法形成多重选择的交叉矩阵。
想象一下,假设有 cpu arch(x86/amd64/arm64/aarch64/riscv…) - generators (ninja/virsual studio) - toolchains (clang/gcc/llvm/msvc/msvc-clang-cl/…/cross-compilers) - build-types (debug/release/relminsize/relwithdebug) - package-types (deb/rpm/msi) 这样的选择器形成的构建配置矩阵,那么你就能管理大型复杂的构建需求,这是采用 CMAKE_BUILD_TYPE 所无法达到的能力。
CMake Presets 就是为此而服务的。
一个 CMakePresets.json
文件,大致有如下的格式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{
"version": 3,
"cmakeMinimumRequired": {
"major": 3,
"minor": 21,
"patch": 0
},
"configurePresets": [
{
// ...
},
{
// ...
},
// Add more presets here
],
"buildPresets": [
{
// ...
},
// ...
],
"testPresets": [
{
// ...
},
// ...
]
}
你可以使用 CMake 命令行方式来添加上面框架结构中的 sections。
例如:
1
2
3
4
5
6
7
8
9
10
11
# 添加一个 configurePreset section
cmake --preset <preset-name>
# 添加一个 buildPreset section
cmake --build --preset <preset-name>
# 添加一个 testPreset section
cmake --build --preset <preset-name> --target test
# 添加一个 packagePreset section
cmake --build --preset <preset-name> --target package
关于每个 sections 的细节,以及命令的解说,请在官方文档中搜索:official CMake documentation。
最小能有多小?只有版本号宣告:
1
2
3
{
"version": 6
}
和
1
2
3
4
{
"version": 8,
"$schema": "https://cmake.org/cmake/help/latest/_downloads/3e2d73bff478d88a7de0de736ba5e361/schema.json"
}
This file
provides a machine-readable JSON schema for theCMakePresets.json
format.
官方文档给出了一个比较真实的样例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
{
"version": 6,
"cmakeMinimumRequired": {
"major": 3,
"minor": 23,
"patch": 0
},
"include": [
"otherThings.json",
"moreThings.json"
],
"configurePresets": [
{
"name": "default",
"displayName": "Default Config",
"description": "Default build using Ninja generator",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/default",
"cacheVariables": {
"FIRST_CACHE_VARIABLE": {
"type": "BOOL",
"value": "OFF"
},
"SECOND_CACHE_VARIABLE": "ON"
},
"environment": {
"MY_ENVIRONMENT_VARIABLE": "Test",
"PATH": "$env{HOME}/ninja/bin:$penv{PATH}"
},
"vendor": {
"example.com/ExampleIDE/1.0": {
"autoFormat": true
}
}
},
{
"name": "ninja-multi",
"inherits": "default",
"displayName": "Ninja Multi-Config",
"description": "Default build using Ninja Multi-Config generator",
"generator": "Ninja Multi-Config"
},
{
"name": "windows-only",
"inherits": "default",
"displayName": "Windows-only configuration",
"description": "This build is only available on Windows",
"condition": {
"type": "equals",
"lhs": "${hostSystemName}",
"rhs": "Windows"
}
}
],
"buildPresets": [
{
"name": "default",
"configurePreset": "default"
}
],
"testPresets": [
{
"name": "default",
"configurePreset": "default",
"output": {"outputOnFailure": true},
"execution": {"noTestsAction": "error", "stopOnFailure": true}
}
],
"packagePresets": [
{
"name": "default",
"configurePreset": "default",
"generators": [
"TGZ"
]
}
],
"workflowPresets": [
{
"name": "default",
"steps": [
{
"type": "configure",
"name": "default"
},
{
"type": "build",
"name": "default"
},
{
"type": "test",
"name": "default"
},
{
"type": "package",
"name": "default"
}
]
}
],
"vendor": {
"example.com/ExampleIDE/1.0": {
"autoFormat": false
}
}
}
这个样例基本上说明了 Presets 可以做些什么事。配合我们的“什么是 CMake Presets” 小节应该能够足以令你理解到你能用 Presets 去干点啥。
如前所述,编写一个 CMakePresets.json 文件的大部分任务是编制 configurePresets,然后在此基础上再追加其他如 buildPresets 等等。
一个 configurePreset section 可以是这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"version": 6,
"configurePresets": [
{
"name": "debug",
"displayName": "Debug",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/debug",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug",
"CMAKE_CXX_FLAGS": "-DDEBUG=1"
}
}
]
}
它指定了一个名为 Debug(displayName) 的 configurePreset,并和 CMAKE_BUILD_TYPE=Debug 勾连起来,所以能够继承该构建类型下辖的一系列默认设定和扩展方案(例如 CMAKE_DEBUG_CXX_FLAGS 等等)。此外,它还自动关联了 C++ 宏定义 DEBUG=1,这往往是 C++ 源代码开发中所需要的关键宏。
顺理成章地,可以追加一个 Release preset:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
"version": 6,
"configurePresets": [
{
"name": "debug",
"displayName": "Debug",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/debug",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug",
"CMAKE_CXX_FLAGS": "-DDEBUG=1"
}
},
{
"name": "release",
"displayName": "Release",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/release",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"CMAKE_CXX_FLAGS": "-DNDEBUG=1"
}
}
]
}
这里 NDEBUG 是源自 MSVC 传统定义,但分配了一个数值,以便有时候能够编写这样的 C++ 代码:
1
2
3
#if NDEBUG
#else
#endif
而不是:
1
2
3
4
5
6
7
#ifdef NDEBUG
#else
#endif
#if defined(NDEBUG)
#else
#endif
事实上,一个完整的跨平台的 C++ 类库可能会有类似于下面的代码来容错:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#if defined(NDEBUG)
#define _NDEBUG 1
#else
#define _NDEBUG 0
#endif
#if defined(NDEBUG)
#undef NDEBUG
#endif
#define NDEBUG _NDEBUG
#if NDEBUG
// ...
#endif
但如果你的类库使用了我们这里的 CMakePresets.json 的话,上面的防错代码就是多余的了,你就可以直接使用 #if NDEBUG
。
这一思考也同样作用于 DEBUG 和 _DEBUG 宏定义上。
你还可以继续添加 RelWithDebug,RelMinSize presets。这样的一套最简单的 presets,实际上就是 vscode cmake-tools 自动为你准备的默认 presets。如下图所示,一个 cmake c++ 项目在 vscode 中可以选择当前活动的配置集,此时就会弹出选择框自动向你提供 4 个标准的 presets:
和
我们写这么一个 “Default” 的预设集 json 文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
{
"version": 6,
"configurePresets": [
{
"name": "debug",
"displayName": "Debug",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/debug",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug",
"CMAKE_CXX_FLAGS": "-DDEBUG=1 -g -O0"
}
},
{
"name": "release",
"displayName": "Release",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/release",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"CMAKE_CXX_FLAGS": "-DNDEBUG=1 -O3"
}
},
{
"name": "rel-with-dbg-info",
"displayName": "Release with debug information",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/relwithdbginfo",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "RelWithDebInfo",
"CMAKE_CXX_FLAGS": "-DNDEBUG=1 -DWITH_DEBUG_INFO -g -O1"
}
},
{
"name": "rel-with-min-size",
"displayName": "Release with minimal size",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/relwithminsize",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "MinSizeRel",
"CMAKE_CXX_FLAGS": "-DNDEBUG=1 -DWITH_MIN_SIZE -O0"
}
}
],
"buildPresets": [
{
"name": "debug",
"configurePreset": "debug"
},
{
"name": "release",
"configurePreset": "release"
},
{
"name": "rel-with-dbg-info",
"configurePreset": "rel-with-dbg-info"
},
{
"name": "rel-with-min-size",
"configurePreset": "rel-with-min-size"
}
]
}
它和 vscode cmake-tools extension 的默认设定非常相似,并在此基础上增加了一些宏定义来帮助你更好地编写代码。
如果你正在开发一个广泛适配的项目(例如你正在管理一个 Debian 源项目,或者你准备开发一个项目申请放入 Debian 标准源中),比方说你需要你的项目能够在多种平台上以多种不同的设定来进行编译,很容易想到,反复编写那些相似却又似是而非的 json entries 着实是一种折磨,而且如果有一点变动的话,数十个 config set 可能都要同步修订。基本上这是一种灾难。
类似地,前文中我们提出了这样的假设:假设有 cpu arch(x86/amd64/arm64/aarch64/riscv…) - generators (ninja/virsual studio) - toolchains (clang/gcc/llvm/msvc/msvc-clang-cl/…/cross-compilers) - build-types (debug/release/relminsize/relwithdebug) - package-types (deb/rpm/msi) 这样的选择器形成的构建配置矩阵。此时我们也需要在哪怕 configurePreset section 之中也能有进一步的模组化能力,才能设计出上面的不同的配置风味。
对于这些要求,解决的办法是利用 hidden: true
来建立能够被重用的子集,然后利用 inherit include 来组合这些子集。
例如我们可以预设 use-clang 和 use-gcc 两个子集:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
{
"version": 6,
"cmakeMinimumRequired": {
"major": 3,
"minor": 25,
"patch": 0
},
"configurePresets": [
{
"name": "debug-build",
"hidden": true,
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug",
"USE_DEBUG_OUTPUT": "ON",
"USE_LOG": "ON",
"USE_LOG_SEVERITY": "0"
}
},
{
"name": "release-build",
"hidden": true,
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"USE_DEBUG_OUTPUT": "OFF",
"USE_LOG": "OFF"
}
},
// ...
}],
// ...
}
注意到它们都设定了 "hidden": true
,这样就不会在各种 UI 列表中出现了。例如 Clion 的 Run Configurations 列表中,就不会有 “debug-build”,“release-build” 条目供选择,取而代之的将是引用这些子集的那些 sections。如果你实在使用 vscode cmake-tools Extension,那里也有类似的关于 cmake Configurations 选择列表(前文的图示中已经有所体现)。
然后在稍后的 configurePresets section 中,我们可以这样利用它们:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
{
"version": 8,
"cmakeMinimumRequired": {
"major": 3,
"minor": 25,
"patch": 0
},
"configurePresets": [
{
"name": "debug-build",
"hidden": true,
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug",
"USE_DEBUG_OUTPUT": "ON",
"USE_LOG": "ON",
"USE_LOG_SEVERITY": "0"
}
},
{
"name": "release-build",
"hidden": true,
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"USE_DEBUG_OUTPUT": "OFF",
"USE_LOG": "OFF"
}
},
{
"name": "use-clang",
"hidden": true,
"cacheVariables": {
"CMAKE_C_COMPILER": "clang",
"CMAKE_CXX_COMPILER": "clang++",
"CMAKE_CXX_FLAGS": "-stdlib=libc++",
"CMAKE_EXE_LINKER_FLAGS": "-stdlib=libc++",
"CMAKE_SHARED_LINKER_FLAGS": "-stdlib=libc++"
}
},
{
"name": "use-gcc",
"hidden": true,
"cacheVariables": {
"CMAKE_C_COMPILER": "gcc",
"CMAKE_CXX_COMPILER": "g++"
}
},
{
"name": "linux-clang-debug",
"displayName": "Linux clang debug",
"inherits": [
"use-clang",
"debug-build"
]
},
// ...
}],
// ...
}
如例所示,我们继续定义了 use-clang, use-gcc 两个子集,然后定义了 linux-clang-debug 这个真正的配置项目,它的 inherits section 中引用了 use-clang 和 debug-build 这两个子集,这就形成了两级维度的风味矩阵。所以当我们在 vscode 中点击状态栏中的 No Configure Preset Selected (如下图)
时,弹出的列表会是这样:
你能看到 linux clang debug 出现在列表中。
而它的具体配置细节则由 use-clang 和 debug-build 这两个子集中的设定项来决定。
注意到图示中有更多的配置集出现在列表对话框zhong,那是因为我们还一股脑儿地添加了更多的配置。
所以完整的示例(CMakePresets.json
)可以是这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
{
"version": 6,
"cmakeMinimumRequired": {
"major": 3,
"minor": 25,
"patch": 0
},
"configurePresets": [
{
"name": "use-ninja",
"hidden": true,
"generator": "Ninja",
"cacheVariables": {
"CMAKE_EXPORT_COMPILE_COMMANDS": "ON"
}
},
{
"name": "default-build-dir",
"hidden": true,
"binaryDir": "${sourceDir}/build"
},
{
"name": "debug-build",
"hidden": true,
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug",
"USE_DEBUG_OUTPUT": "ON",
"USE_LOG": "ON",
"USE_LOG_SEVERITY": "0"
}
},
{
"name": "release-build",
"hidden": true,
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"USE_DEBUG_OUTPUT": "OFF",
"USE_LOG": "OFF"
}
},
{
"name": "release-with-min-size-build",
"hidden": true,
"cacheVariables": {
"CMAKE_BUILD_TYPE": "MinSizeRel",
"USE_DEBUG_OUTPUT": "OFF",
"USE_LOG": "OFF"
}
},
{
"name": "release-with-debug-build",
"hidden": true,
"cacheVariables": {
"CMAKE_BUILD_TYPE": "RelWithDebInfo",
"USE_DEBUG_OUTPUT": "OFF",
"USE_LOG": "OFF"
}
},
{
"name": "use-clang",
"hidden": true,
"inherits": [
"default-build-dir",
"use-ninja"
],
"cacheVariables": {
"CMAKE_C_COMPILER": "clang",
"CMAKE_CXX_COMPILER": "clang++",
"CMAKE_CXX_FLAGS": "-stdlib=libc++",
"CMAKE_EXE_LINKER_FLAGS": "-stdlib=libc++",
"CMAKE_SHARED_LINKER_FLAGS": "-stdlib=libc++"
}
},
{
"name": "use-gcc",
"hidden": true,
"inherits": [
"default-build-dir",
"use-ninja"
],
"cacheVariables": {
"CMAKE_C_COMPILER": "gcc",
"CMAKE_CXX_COMPILER": "g++"
}
},
{
"name": "use-msvc-cl",
"hidden": true,
"inherits": [
"default-build-dir",
"use-ninja"
],
"cacheVariables": {
"CMAKE_C_COMPILER": "cl",
"CMAKE_CXX_COMPILER": "cl"
}
},
{
"name": "use-msvc-clang-cl",
"hidden": true,
"inherits": [
"default-build-dir",
"use-ninja"
],
"cacheVariables": {
"CMAKE_C_COMPILER": "clang-cl",
"CMAKE_CXX_COMPILER": "clang-cl"
}
},
{
"name": "linux-clang-debug",
"displayName": "Linux clang debug",
"inherits": [
"use-clang",
"debug-build"
]
},
{
"name": "linux-clang-release",
"displayName": "Linux clang release",
"inherits": [
"use-clang",
"release-build"
]
},
{
"name": "linux-gcc-debug",
"displayName": "Linux gcc debug",
"inherits": [
"use-gcc",
"debug-build"
]
},
{
"name": "linux-gcc-release",
"displayName": "Linux gcc release",
"inherits": [
"use-gcc",
"release-build"
]
},
{
"name": "windows-arch-x64",
"hidden": true,
"architecture": {
"value": "x64",
"strategy": "external"
},
"toolset": {
"value": "host=x64",
"strategy": "external"
}
},
{
"name": "windows-default",
"displayName": "Windows x64 Debug",
"hidden": true,
"inherits": [
"use-msvc-cl",
"windows-arch-x64"
],
"vendor": {
"microsoft.com/VisualStudioSettings/CMake/1.0": {
"hostOS": [
"Windows"
]
}
}
},
{
"name": "windows-debug",
"displayName": "Windows x64 Debug",
"inherits": [
"windows-default",
"debug-build"
]
},
{
"name": "windows-release",
"displayName": "Windows x64 Release",
"inherits": [
"windows-default",
"release-build"
]
},
{
"name": "ci-options",
"hidden": true,
"cacheVariables": {
"BUILD_TESTING": "ON",
"BUILD_DEMO_VIEWER": "OFF",
"DISABLE_OPTIMIZING": "ON"
},
// "toolchainFile": "vcpkg/scripts/buildsystems/vcpkg.cmake"
},
{
"name": "windows-ci",
"description": "used by the ci pipeline",
"inherits": [
"windows-release",
"ci-options"
],
"cacheVariables": {
"INSTALL_DEPENDENCIES": "ON",
"ADDITIONAL_LIBARIES_PATHS": "${sourceDir}/build/vcpkg_installed/x64-windows/bin"
},
"environment": {
"PROJ_LIB": "${sourceDir}/build/vcpkg_installed/x64-windows/share/proj"
}
},
{
"name": "linux-ci",
"description": "used by the ci pipeline",
"inherits": [
"release-with-debug-build",
"use-gcc",
"ci-options"
],
"cacheVariables": {
"CMAKE_CXX_FLAGS": "--coverage"
},
"environment": {
"PROJ_LIB": "${sourceDir}/build/vcpkg_installed/x64-linux/share/proj"
}
},
{
"name": "linux-ci-release",
"description": "used by the ci pipeline for releasing",
"inherits": [
"release-build",
"linux-gcc-release"
],
"cacheVariables": {
"BUILD_TESTING": "OFF",
"BUILD_DEMO_VIEWER": "OFF",
"USE_MEMORY_MAPPED_FILE": "ON"
}
},
{
"name": "macos-ci",
"description": "used by the ci pipeline",
"inherits": [
"use-ninja",
"default-build-dir",
"debug-build",
// "release-with-debug-build",
"ci-options"
],
"cacheVariables": {
"CMAKE_CXX_FLAGS": "-fprofile-arcs -ftest-coverage"
},
"environment": {
"PROJ_LIB": "${sourceDir}/build/vcpkg_installed/x64-osx/share/proj"
}
},
{
"name": "Debug",
"description": "general debug building",
"inherits": [
"use-ninja",
"debug-build",
"default-build-dir",
"ci-options"
],
"cacheVariables": {
},
"environment": {
"PROJ_LIB": "${sourceDir}/build/vcpkg_installed/x64-osx/share/proj"
}
}
],
"buildPresets": [
{
"name": "windows-debug",
"configurePreset": "windows-debug"
},
{
"name": "windows-release",
"configurePreset": "windows-release"
},
{
"name": "linux-clang-debug",
"configurePreset": "linux-clang-debug"
},
{
"name": "linux-clang-release",
"configurePreset": "linux-clang-release"
},
{
"name": "linux-gcc-debug",
"configurePreset": "linux-gcc-debug"
},
{
"name": "linux-gcc-release",
"configurePreset": "linux-gcc-release"
},
{
"name": "windows-ci",
"configurePreset": "windows-ci"
},
{
"name": "linux-ci",
"configurePreset": "linux-ci"
},
{
"name": "linux-ci-release",
"configurePreset": "linux-ci-release"
},
{
"name": "macos-ci",
"configurePreset": "macos-ci"
},
{
"name": "Debug",
"configurePreset": "Debug"
}
],
"testPresets": [
{
"name": "test-default",
"hidden": true,
"output": {
"outputOnFailure": true
},
"execution": {
"noTestsAction": "error",
"stopOnFailure": false
}
},
{
"name": "windows-ci",
"configurePreset": "windows-ci",
"inherits": [
"test-default"
]
},
{
"name": "linux-ci",
"configurePreset": "linux-ci",
"inherits": [
"test-default"
]
},
{
"name": "macos-ci",
"configurePreset": "macos-ci",
"inherits": [
"test-default"
]
},
{
"name": "Debug",
"configurePreset": "Debug",
"inherits": [
"test-default"
]
}
]
}
这是一个充分完整的实例。
你可以注意到实际上每个 sections(configurePresets,buildPresets,…),都支持利用 hidden 的方式来定义子集,然后通过组合子集的方式来构成多维度的风味矩阵。
通过这种方式,我们可以设计出超级复杂的构建配置集合。尽管复杂的构建配置总归是一种灾难,但事实就是越是疯狂离谱的需求,越是会在你的职业生涯中出现。所以我们寻找工具时总是应该是“无论我要不要,你得要有”,或者我们设计工具时也都应该是“无论 somebody 要不要,我总是要有”。
放飞自我时间到!
新年了,又老了一岁。以下省略万多字……
🔚
]]>接续上回的 两个 Golang 无锁编程技法 和 两个 Golang 无锁编程技法 [续] ,接下来讨论一下小内存的大量、频繁分配带来的问题及其优化思路。前两篇投身于锁和它的轻量化,衍生出来的技巧的基本上是采用以空间换性能的想法,通过制造共享数据的副本,仅在必须的时刻才回写。
例如 Entry 模式的后果就是带来大量的小块内存的分配。这对于高频交易肯定是膨胀的代价,难以接受。而像 Swap 技巧需要将数据先复制、再修改、然后再回写,这能缩短加锁时长、减小锁定面积,但 contents 尺寸很大的时候显然就难以忍受了。
所以这些场景就受到限制,它们不是万能技能。
在轻度受限的场景强行使用这些技巧,或者使用类似的思路,或者你的场景面临着同样的小内存分配问题,例如大量小字符串的场景,以上这些情况就提出了辅助手段:
可能这里并不能罗列全部,以后整理脑瓜子了再继续。
sync.Pool 是一个单个类型对象的复用技术。它解决的问题是 Entry 模式这样的场景:以 Logger 开发需求为例,Entry 会包装一个用于格式化字符串的缓冲区,然后将用户的 Attributes 以及 Message 格式化后写出到输出设备。
所以 Entry 可以利用 sync.Pool 来管理这个用给格式化的缓冲区。就像这样子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
var printCtxPool = sync.Pool{New: func() any {
return newPrintCtx()
}}
func newPrintCtx() *PrintCtx {
return &PrintCtx{
buf: make([]byte, 0, 1024),
noQuoted: true,
clr: clrBasic,
bg: clrNone,
}
}
type PrintCtx struct {
buf []byte
// ...
}
//... codes for printCtx ignored
// The logger struct
type logimp struct {
*entry
}
type entry struct {
// ...
}
func (s *entry) print(ctx context.Context, lvl Level, timestamp time.Time, stackFrame uintptr, msg string, kvps Attrs) (ret []byte) {
pc := printCtxPool.Get().(*PrintCtx)
defer func() {
printCtxPool.Put(pc)
}()
// pc := newPrintCtx(s, lvl, timestamp, stackFrame, msg, kvps)
// pc.set will truncate internal buffer and reset all states for
// this current session. So, don't worry about a reused buffer
// takes wasted bytes.
pc.set(s, lvl, timestamp, stackFrame, msg, kvps)
return s.printImpl(ctx, pc)
}
上面的节选代码(hedzr/logg/slog,暂未发布)展示了 logger’s entry 怎么将潜在公共数据包装到 PrintCtx 之中,然后通过 sync.Pool 缓存这些 *PrintCtx 对象。如果存在并发请求,同时有多个 go routines 发出了 log.Info 这样的日志输出请求,那么 sync.Pool 会分别为每个请求分配出相应的 PrintCtx 对象,这些对象在输出完成之后被归还到 sync.Pool 的内部池中,下一次请求时就会被再次复用。
没有进一步贴出的代码(pc.set()
)还包括在拿出 PrintCtx 对象时将其 buf 清零(但并不释放其已分配的空间)。这是为了让每次日志输出时格式化结果不会混淆。
在我们的实现中,entry 这个结构体实现了 Entry 模式。你可以任意创建顶级 Default() Logger 的子 Logger,这些 children and siblings 彼此之间互不干扰。除此而外,内部代码也多次临时地使用子 Logger 的方式来叠加一些临时状态,同样地新的临时对象也不会和原始 Logger 互相干扰。
有必要强调的是,sync.Pool 起到了防止频繁分配对象的作用,只有当并发请求更多时才会 New 出新对象。
如果你总是借助于 sync.Pool 做单输入单输出,那么下面的泛型函数能够帮助你:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func Pool[T any, Out any, In any](generator generatorT[T], cb func(T, In) Out) func(In) Out {
pool := sync.Pool{New: func() any { return generator() }}
return func(in In) Out {
obj := pool.Get().(T)
defer func() {
if r, ok := any(obj).(interface{ Reset() }); ok {
r.Reset()
}
pool.Put(obj)
}()
return cb(obj, in)
}
}
type generatorT[T any] func() T
使用它的方式是:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var objHelper = Pool(newBuf, func(buf *Buf, txt string) []byte {
buf.buf = append(buf.buf, txt...)
return buf.buf
})
func newBuf() *Buf { return &Buf{} }
type Buf struct {
buf []byte
}
func run() {
buf := objHelper("hello")
fmt.Printf("Buf: %v\n", buf)
}
这会省却不少事。
遗憾的是 Golang 泛型无法支持 varidic parameters,所以上面的 Pool 只能单输入(here is string)单输出(here is []byte),而不能有多个输出,例如(ctx context.Context, lvl Level)之类。
所以面对一个残缺的泛型,我只能说想说爱你并不容易。
能不能通过高阶函数解决这个问题呢?
我稍微尝试过,不行。
但那可能只是我尝试的粗糙,或许以后找个时间认认真真设计一下,或许就行了呢。
这个技术,在 Golang 基本上不可能实现,或者说相当难过。你需要手动管理 unsafe.Pointer 的方式来处理一块内存。但由于 Golang 不支持 C/C++ 那样的内存提取方式,所以你要放入和抽出数据实体都需要非常小心,以免导致错误放置。
但在 C++11 之后,这个技术能够很有效,编码也非常典雅。
这里不做示例了,标题已经完全体现了算法的核心思想。
sync.Pool 实际上干的就是资源池的事情。
但它的问题在于 sync.Pool 是按需分配的,这对于高频交易并不友好。
传统的连接池做的方案是预先分配例如 500 个连接对象,然后随取随用。如果并发请求超过 500 那就返回连接池忙的错误。
类似地,工作线程池等场景都是相似的方案。
在 Golang 中借助于诸如 WaitGroup 等的方式可以自行实现这样的资源池,难度不大,所以本文点到为止,xxxx。
有时候,你也可以变相地利用 sync.Pool 来预先分配若干实例,方法也不困难:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
var pool = sync.Pool{
New: func() []byte {
return make([]byte, 0, 4096)
}
}
var tmp [200][]byte
for i:=0; i<200; i++ {
tmp[i] = pool.Get().([]byte)
}
for i:=0; i<200; i++ {
pool.Put(tmp[i])
}
tmp = nil
惟其难免有点弱智。
在 Golang 新版本中,很多包中添加了 AppendXXX 函数集。
例如 bytes.AppendInt/AppendFloat/…, fmt.AppendFormat(…) 等等。
它们的用处在于在既有的缓冲区中追加数值,而不是新分配一个缓冲区来格式化数值,然后合并两个缓冲区。
比较两种做法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func f1() {
var buf = make([]byte, 0, 1024)
buf = strconv.AppendBool(buf, true)
buf = strconv.AppendInt(buf, 1, 10)
buf = strconv.AppendQuoteRune(buf, 'V')
println(string(buf))
}
func f2() {
var buf string
buf = buf + strconv.FormatBool(true)
buf = buf + strconv.Itoa(1)
buf = buf + string('V')
println(buf)
}
f1 显然性能更好,使用资源(内存和CPU)的效率也更高。
这也是当前 Golang 中处理小字符串的通行办法。而且 buf 这个变量还可以使用 sync.Pool 略加复用,从而进一步提高复用程度,减少分配数量。
在性能调优阶段,可以通过 Profiling 观察对象分配次数,以此手段来帮助解决小内存分配问题。
我以前旧文曾经探讨过 Profiling 和 pprof 有关的内容。这里就不想重复了。
放飞自我时间到!
还是不要飞这一次。
完全是个人经验,所以连命名也都是自己命的,也就没什么外部参考了。
或者,也可能是有的,但我都说了,冷的手脚都僵了不是,就不找了。
🔚
]]>接续上回的 两个 Golang 无锁编程技法,继续讨论无锁技法。前篇的思想算是比较激进的吧,属于纯粹的 nolock programming 讨论。
尽管我们的理想是全面无锁,但事实上这是不可能的。公共的数据就会带来竞争,竞争带来的负面代价就是锁定,无论采用何种思路,只要有公共数据的共用需求,那各种技巧都不能最终将锁定抹除。所以除开纯粹的 nolock programming 之外,lock-free 也是一种可堪接受的实作范式。一般来说,泛泛讨论无锁编程时多数人其实都是在渴求 lock-free,因为想要拒绝加锁既然不可能,那就 CAS 吧,足够轻量了。如果连 lock-free 也都做不到,那就 RW locking 也好过 mutex/critical-section。
所以从编码的角度来看,我们一方面要尽可能减少锁定区域面积,一方面要设法避免锁定。这就引出如下几个惯用法:
可能这里并不能罗列全部,以后整理脑瓜子了再继续。
Golang 提供的 channel 基本上对于初学者来说是个神奇的东西。对于 C++ 转来的人来说它也比较抽象,还好不至于无法理解,毕竟都是经过 C++ 毒打的人了,还能有什么东西能让我惊惧呢。
不过,所谓 channel 避开加锁或者通过 channel 共享数据以消解 dataracing,在本质上依赖于两点:
下面的示例解释了数据所有权转交的含义:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
func (s *connS) Write(data []byte) (n int, err error) {
if n = len(data); n > 0 {
s.chWrite <- data
}
return
}
func (s *connS) rawWriteNow(data []byte, deadline time.Duration) (n int, err error) {
err = s.conn.SetWriteDeadline(time.Now().Add(deadline))
if err == nil {
n, err = s.conn.Write(data)
}
return
}
func (s *connS) serve(ctx context.Context, w api.Response, r api.Request) {
defer s.Close()
s.Verbose("[connS] looper - entering...")
go s.readBump(ctx, w, r)
writeBump:
for {
select {
case <-ctx.Done():
s.Debug("[connS] looper/writeBump ended.")
break writeBump
case data := <-s.chWrite:
s.Verbose("[connS] rawWriteNow wake up")
if _, err := s.rawWriteNow(data, s.writeTimeout); err != nil {
s.handleError(err, "[connS] Write failed")
break writeBump
}
}
}
}
代码节选自 go-socketlib 中 server 部分。
由于数据在通过 channel (s.chWrite
)传输之后,拥有者事实上就必须放弃其所有权,所以在任一瞬间,数据都没有被超过一人所共享,那就没有竞争了。这其实是变相的另一种 Entry 模式,对不对。
Share Memory By Communicating - The Go Programming Language 提供了关于通过 channel 共享内存数据块的更官方的表述。但这篇文章有它自己的服务目标。而本文从新描述这个套路,是为了揭示其本质:放弃所有权(不仅仅是读写权利)来避开数据共享,从而避开加锁。
Golang channel 的源码中揭示了在特定情况下会隐含地调用加锁操作来保护数据块安全,所以有的时候通过 channel 传输/转交数据未必是真的无锁。
有兴趣可以查阅 Golang 源码有关 chansend.c 的部分。
这方面,针对讨论 channel 的 concurrent 编程的文章很多,所以我的重心并非讨论它,只做上面的表述就够了。
在很多时候是必须使用 Mutex 的。例如你需要并发安全的 map 的时候,是不是就要加上一颗读写锁?然后代码就好像这样:
1
2
3
4
5
6
7
8
9
10
11
12
type SharedMap struct {
m map[string]any
rw sync.RWMutex
}
func (s *SharedMap) Get(key string) (val any) {
s.rw.Lock()
defer s.rw.Unlock()
return s.m[key]
}
// ...
然而真实的业务开发中,往往会有复杂的逻辑。例如下面的函数片段:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func (s *streamBufS) WriteRune(r rune) (n int, err error) {
s.rw.Lock()
defer s.rw.Unlock()
// Compare as uint32 to correctly handle negative runes.
if uint32(r) < utf8.RuneSelf {
err = s.writeByte(byte(r))
return 1, err
}
s.lastRead = opInvalid
m, ok := s.tryGrowByReslice(utf8.UTFMax)
if !ok {
m = s.grow(utf8.UTFMax)
}
s.buf = utf8.AppendRune(s.buf[:m], r)
return len(s.buf) - m, nil
}
这个片段展示了某种可能的复杂的情景。它功能正常,唯一的问题是 WriteRune 的整体全都处于锁定之中,你也几乎没有办法优化它,读写锁在这里只能以最沉重的方式(写锁定)完全锁住 streamBufS 实体。
在某个应用场景中我们要求必须对 WriteRune 进行优化,降低其锁定粒度和强度。怎么办呢?这就需要 swap 模式了。
说到底,swap 模式的核心思想就是首先在一个副本上完成修改,然后将副本替换(swap)回来。由于替换一个指针或者 swap 一块内存往往只是一条机器指令就能完成的任务,所以加锁的需求就只在这一条指令上,这就将粒度最小化了。
回到上面的函数而言,示意性的改写如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func (s *streamBufS) WriteRune(r rune) (n int, err error) {
// Compare as uint32 to correctly handle negative runes.
if uint32(r) < utf8.RuneSelf {
err = s.WriteByte(byte(r)) // =locked writeByte
return 1, err
}
s2 = s.clone()
m, ok := s2.tryGrowByReslice(utf8.UTFMax)
if !ok {
m = s2.grow(utf8.UTFMax)
}
s2.buf = utf8.AppendRune(s2.buf[:m], r)
n := len(s2.buf) - m
s.rw.Lock()
defer s.rw.Unlock()
copy(s.buf, s2.buf)
s.lastRead = opInvalid
return
}
func (s *streamBufS) clone() *streamBufS {
s.rw.RLock()
defer s.rw.RUnlock()
ns := &streamBufS {
off: s.off,
lastRead: s.lastRead,
split: s.split,
}
copy(ns.buf, s.buf)
return ns
}
改写的代码中,s2 是 s 的一个副本,它(clone()
)会要求一次 RLock,但这自然是远远轻于 Lock 的。最为耗时的 tryGrowByReslice 和 grow 都在 s2 上以不加锁的方式进行。真正需要 Lock 的被减少到 line17~19 这两行。其中 copy() 是 Golang 的内部函数,它的具体实现可以是 repnz movsd 这样的单条 CPU 指令,本身是高效的并自带锁定性。 然后下一行是轻量的赋值语句,只占用单个 CPU 时钟周期。所以这三行的重度锁定的代价极低。
在 C++ 中使用 swap 模式的例子可以更清晰:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void append(T&& el, std::function<bool precond(T&&)> cb) {
std::list<T> cache;
{
rlock _l(&this->rwl); // read lock to this->data
cache.swap(this->data);
}
// assume this is a heavy operation
if (cb(el))
cache.push_back(el);
{
wlock _l(&this->rwl); // write lock to this->data
this->data.swap(cache);
}
}
这段代码仅作示例,不算太严谨(line 5 是存在问题的),但足以展示 swap 模式的特点:在一个副本上完成操作任务,然后替换回到数据本体(this->data
),那么加锁面积就最小化了。
这一条倒是没什么可说的。
无非是传统上使用 Mutex 来保护数据。但如果是典型的生产者-消费者模型,那么使用读写锁 RWMutex 能够更轻量,尤其对于多消费者读的场景,读写锁的读锁定代价轻量的多,划算的多。而进一步,如果能够使用原子操作的话,比读写锁就更轻量。
原子操作又是构成 CAS 的基石。最初(80286时代)原子操作只能被表现为 lock inc(即加锁的 i++),奔腾 CPU(?待查待确认,但大体上可能没记错吧)之后才有了(CompareAndSwap,CAS)的机器指令形式。所以 CAS 听起来多高大上的,本质上也就是单条机器指令加上锁定地址总线的体现。进一步地,操作系统原理教材中的各种各样的锁,都要变相地表现为指令流水线、CPU 片上缓存、地址总线锁的某种恰当的组合。只不过这个东西就不是本文(乃至于本系列)所能够展开的了。
另外,对于复杂的数据体来说,原子操作应该是不够用的,我们还是必须要用到 Mutex 的各种形式。
放飞自我时间到!
还是不要飞这一次。
完全是个人经验,所以连命名也都是自己命的,也就没什么外部参考了。
或者,也可能是有的,但我都说了,冷的手脚都僵了不是,就不找了。
另外本文中内容同样地没有做代码层面的严格测试,不妨将它们当作是伪码,不要挑剔我的示例代码不太对,因为我确实只是思想去到了那么远,手嘛,太冻了,就没有去到 Goland 那边了。
🔚
]]>对于无锁编程来讲,无锁只是一个表象,编程者设法组织 data+processing,其聚焦点在于如何消除 dataracing(竞态条件)。而消除 dataracing 而言,古典的并发编程逻辑是采用关键区、排他性锁、读写锁等方式来保护 data 不会因为 processing 而导致错误读或错误写,那么对于无锁编程来说,则是通过消除 data 的共享性,或者消除并发操作 data 等方式来解决问题。
所以最典型的无锁编程技法包含两个技巧:
其他的技法都是相似思路的具体衍生。
这里的“其他的技法”,是指完全不使用锁定或 CAS 的算法设计方案。
本文中讨论的是如何设计数据结构及其处理器(算法)来防止加锁,甚至于即使是 CAS 也去除。
至于在共享性的 data 上强行消除竞争读写问题的其他无锁方案,例如我曾经编写过 MPMC 的 RingBuffer 工具类库,这些路线则完全依赖于具体的 data 实体并在具体的 CPU 上进行设计,基本上是一种很受限的技法,不具备高层次抽象层面的通用性。尽管其具体的设计思路有迹可循(例如),但却不是最佳的选择:一个优秀的 MPMC RingBuffer,它的致命之处就在于在单核心的 CPU 上,或者单颗 vCPU 的 vHost 上可能是无法降级工作的,或者即使能工作也是低效的或者高消费的。
所以本文讨论的是更通用的,在任何高级语言(无论其线程、协程支持度如何)于任何 Targets 上都可以通行的无锁编程技法。
Structure 副本手段是一种经典的无锁编程技法。它的核心思想在于将易变数据一一个 structure 管理,通常可能起名为 Entry,然后在这个 structure 上展开数据处理过程。如此,由于数据处理过程只会操作其所关联到的 structure,因此这一组数据本身就是非共享的,也就无虑 dataracing 的潜在可能性的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type Worker struct {
closed int32 // avoid duplicated release rsrc in Close()
entries []*Entry
}
type Entry struct {
Count int
Data []any
}
func (w *Worker) New(data ...any) *Entry {
e:=&Entry{Data:data}
w.entries = append(w.entries, e)
return e
}
func (e *Entry) Run() {
// processing e.data
}
假设初始化数据 data []any 被提供给 Worker,基于此产生一个 Entry,然后通过 Entry.Run 来处理初始化数据,产生结果,那么上面的示例代码就避免了数据包(Count,Data)的共享问题,因为这个数据包是排他性独立的。
有时候我们的数据包很难拆解,此时可以以衍生技法来实现拆解。具体的思路是设法进行分治。即数据包可以按照推进程度重新设计为 step1,step2,等等,每一步骤中的小数据包的计算结果依次提交给下一步骤。又或者数据包可以拆解为若干个小计项目,最后将多个小计结果合并计算。
无论采取哪种拆解思路,总的思路不变,即单个 processing 所操作的 data 是排他性独立的一个副本,从而从根本上去除共享加锁解锁的需求。
问题在于太多的小块内存(Entry struct)可能是不好的。一方面它可能带来额外的内存消耗(如果正在处理一个巨大的链表、数组之类,你不太可能总是制造它们的副本),另一方面,太多频繁的小块内存的分发和析构也会产生难以接受的开销。有时候,强制这样的小块内存在栈上分配(如果语言或者编译器支持)是解决后一问题的良药。不过更多的情况下、或许这的确就是不可行的。
当然,带有 GC 的语言这方面的压力会小一点。
其次,使用一个预分配的对象池也可以减轻小内存频繁分配的压力。
很多 Logger 都会运用到这种方法。
包括我没有放出来的 logg/slog 也使用了这种方法。我们的 Logger 接口实际上仅仅是简单的包含了一个 Entry 接口:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type Logger interface {
Entry
}
type Entry interface {
// ...
}
type loggerS struct {
//..
*entryS
}
type entryS struct {
//...
skipFrames int
}
func (s *loggerS) WithExtraSkips(frames int) {
return newEntry(frames)
}
func newEntry(frames int) *entryS {
return &entryS{skipFrames:frames}
}
这样的好处是,每个 loggerS 上可以随时低代价地建立 entryS 副本,用以包装特定数据(例如需要额外摘除几个栈帧)。
除了 logger 和 sublogger 之外,很多场景都可以广泛地运用 Entry 模式。例如我这边有一份 Trie tree 实现,也用到了类似的手法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type trieS struct {
root *nodeS
}
type nodeS struct {
fullPath string
matchedKey string
// ...
}
type prefixedTrie struct {
*trieS
prefix string
}
func (s *trieS) newTrie(prefix string) *prefixedTrie {
return &prefixedTrie{s,prefix}
}
其中一个用法是 prefixedTrie,它在原样照搬 trieS 及其操作接口的同时,引入了额外的前缀字段。其用途在于可以在一个给定的前缀路径下操作子树的键。
所以说 Entry 模式可以说是到处都有在用,用途也不一定限于为了无锁。然而凡是运用到该模式的地方,自动获得了无锁的收益,这其实是很划算的。
在 C++ 中使用同样的模型模式就可能会得不到足够的收益了,因为 C++er 可能连这个 new entry 所消耗的小块内存也想优化掉。
说到这样的优化,也有一个思路,即高阶函数。通过匿名函数传参方式,即可利用参数栈来避免小块内存分配。但这个思路也并不安全,这是因为匿名函数或者说高阶函数闭包看似省了一个struct分配,但闭包本身可能会因为本地变量捕俘而隐含地创建一个内部struct。
循环泵模式有时候是一种 design pattern。
它的核心思想是将并行事件强行序列化,然后再一一分发给具体处理程序。于是对于这些具体处理程序来讲,共享数据总是排他性的:因为所有处理程序在同一时间下只会运行一个。
如果具体处理程序总是飞快地完成,没有阻塞的忧虑,那么循环泵模式将会是一个非常好的消费模式。
1
2
3
4
5
6
7
8
func (w *Worker) runLoop() {
for {
switch{
case ev:=<-fileWatcherCh:
onFileChanged(ev)
}
}
}
对于低频事件的分发和简化并在此同时去除加锁需求、提升性能来说,循环泵模式是一时之选。在诸如 TCP/IP 服务器的 incoming data 处理上通常循环泵是最佳选择:对新进连接请求采取 Entry 模式制作一个独立的处理器 connectionProcessor,该处理器中以循环泵模式接受输入数据,识别输入数据的模式为规约命令,然后分发给具体的规约命令处理器。
其问题在于,一个阻塞会破坏整个循环,一个过慢的处理会带来不可知的下一事件的处理延迟,高频率的事件会在分发点上阻塞、堆积,甚至是丢失。
尽管 Bumper Loop 似乎很明显地有串行化的效率劣势,但它仍被广泛地用于 TCP/IP server/client 编程中。例如一个 tcp server 在接受 client 请求建立了新连接之后,新连接对象 connS 就会启动一个 go rountine 开始跑循环泵:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
func (s *serverWrap) makeListener() (err error) {
if s.l == nil {
addr := net.JoinHostPort(s.host, strconv.Itoa(s.port))
s.l, err = net.Listen(s.network, addr)
if err == nil && s.tlsConfig != nil {
s.l = tls.NewListener(s.l, s.tlsConfig)
}
if err == nil {
l := s.l
s.closeListener = l.Close
s.loop = func(ctx context.Context) (err error) {
// timer := time.NewTicker(10 * time.Second)
// defer func() {
// timer.Stop()
// s.debug("[tcp][server] runLoop goroutine exited.")
// }()
if !s.quiet {
s.info("Server starts listening", "at", l.Addr())
}
for {
var conn net.Conn
conn, err = l.Accept()
if err != nil {
if dc, db, _ := s.handleListenError(err); dc {
continue // os.Exit(1)
} else if db {
break
}
}
s.debug("[serverWrap] new incoming connection", "remote", conn.RemoteAddr(), "local", conn.LocalAddr())
if s.onNewResponse == nil {
go newConn(s, conn).run(ctx)
} else {
w := s.onNewResponse.New()
if r, ok := w.(Runnable); ok {
go r.Run()
} else {
// nothing to do, we assume the OnNewResponse handled New()
// which have already created a Response writer and run the
// necessary looper.
}
}
}
s.debug("[serverWrap] server's listener loop ended.")
return
}
}
}
return
}
func newConn(s *serverWrap, conn net.Conn) *connS {
c := &connS{
serverWrap: s,
conn: conn,
tmStart: time.Now().UTC(),
writeTimeout: 5 * time.Second,
chWriteSize: 16,
wl: &sync.Mutex{},
}
s.connections[c] = true
return c
}
type connS struct {
*serverWrap
conn net.Conn
// ...
}
func (s *connS) run(ctx context.Context) {
//...
// fallback to default serve routine
s.serve(ctx, s, s)
}
func (s *connS) serve(ctx context.Context, w Response, r Request) {
// ...
workingLoop:
for {
select {
case <-ctx.Done():
s.debug("[connS] looper ended.")
break workingLoop
case data := <-s.chWrite:
if _, err := s.rawWriteNow(data, s.writeTimeout); err != nil {
s.handleError(err, "[connS] Write failed")
break workingLoop
}
default:
n, err := r.Read(buf[pos : pos+s.bufferSize])
if err != nil {
s.handleReadError(n, err, buf, pos, w, r)
break workingLoop
} else if n == 0 {
time.Sleep(1 * time.Millisecond)
continue
}
nEnd := pos + n
nRead, err = s.onProcessData(buf[:nEnd], w, r)
if nRead <= 0 {
s.warn("[connS] data block decode failed, skipped.", "client.addr", w.RemoteAddr(), "client.id", cidHolder.GetClientID(), "data", buf[:nEnd], "err", err)
pos = s.onCorruptData(buf[:nEnd], w, r)
// ...
continue
}
if err != nil {
s.handleError(err, "[connS] onProcessData(buf, wr) failed.", "client.addr", w.RemoteAddr(), "client.id", cidHolder.GetClientID(), "nRead", nRead)
break workingLoop
}
// ...
}
}
s.tmStop = time.Now().UTC()
}
这是我没有放出的 go-socketlib 的新版代码的节录。
其中 connS.serve() 就是那么一个循环泵模式的应用,for-select 多路分发几种事件,反复处理,只要单次处理足够快,那么工作就良好。反之,例如如果读取一个数据报文解释处理出错的话,可能循环泵就异常退出了,或者不运作了。
所以优势和劣势都明摆着,就看你怎么解了。
Bumper Loop 的问题之一是不太适合高频事件场景,一般来说事件频率在80ms以上时才比较好用。这种尺度的隐喻是说单次事件处理平均耗时应该小于 80ms。
在不那么严格的场景中(例如非金融高频交易),有时候可以在耗时的事件处理器中启动一个 go rountine 去异步地慢慢处理,而主体的 bumpper loop 则继续去分发后继事件。这时候异步 go rountine 又可以用到 Entry 模式,适当地复制少许状态以便解耦竞态条件。
如果你非要问,严格实时的高频交易怎么办。不加锁就竞态,加锁就迟滞,上面的算法似乎不怎么好用的样子。
答案是,这种问题你不该问。
背后的事实是,这样的高频交易是无法简单用几个流行的模式来解决问题的。事实上我们在处理电厂变电所信号时,就是全方面到处动刀。例如设计数据结构和算法需要经过专业调教,使得运算单纯;使用像 PI 数据库这样的工业实时内存数据库来解决数据管理问题和消除数据块在事件处理过程中的无意义的复制;采用小规模的计算节点集群来分散计算压力和容错,等等。
你不该问的原因就是这样,这样的问题的解决方案是一个系统性的问题,没有单一有效的答案。
放飞自我时间到!
今次因为年终了,同时又冷的无法生存(夸张),所以也就不飞了。其实有时候是很多看法的,渐渐的却又说话越来越少。有人说这就是城府深的表现,也有人说这是成长,嗯,这个是双商在线的说法,还有人就说这是老的老不死的,那当然就是双商极低罗。
这次完全是个人经验,所以连命名也都是自己命的,也就没什么外部参考了。
或者,也可能是有的,但我都说了,冷的手脚都僵了不是,就不找了。
🔚
]]>本文并非冬至的详细介绍,只是平日有时看到中文网上到处都是以讹传讹、断章取义,又或是九假一真式的文稿,有时候突然难忍,故而草就而成。因为全数尽皆脑海回忆,所以我也无法保证文内逐字逐句全都正确,不过我认为我的认识大局上还是没有谬误的。不管了,就这样。
如开篇题语,冬至才是节气之首。至于中文网上所说的立春就是第一个节气,总的来说是不正确的。
按照现有的文献与考证,我国农历中包含节气,属于是阴阳合历,而节气中谁是第一呢?在夏商周时期,夏至是第一,随后春秋以降则冬至是第一。
夏商周时夏至为首,这是由某位前辈考证得出的结论,我不记得是谁了,汗,大致是一本殷商历法研究中提到的。
春秋时期,以淮南子经集为依据能够证明冬至为首。这个时期古人已经正确使用天文观测的方法来确定一年(农历年)的首日了,即白天最短、夜间最长,日影最长的那一天(通常以正午测得的日影长度为计量依据,但随着天文观测的进步,后来就可以真正测得和推算出每个农历年的冬至以至于全部二十四节气的精确时分秒了)就是冬至,然后以 15 天为一个节气,依次向后顺排,排得二十四节气后,一个阴历年的 360 天就排出来了。然后结合太阳历的推算,加上闰月损益最终就得到一年的农历排列了。这就是古代天文官员排农历的方法。
当然,具体操作就会复杂很多。这些操作规范当代也有对应的国家标准对其进行描述,显然也是经过了历代以及当代改进的。查询国标农历的编算则可以找到该标准。至于说该标准略过了具体的演算算法以及过程,因为计算模型涉及到天文观测的对照与修订,是一个很复杂且时刻在变的模型,所以国标只会包含框架而不约束具体算法。
古往今来,天文划分法始终占据主流地位,所以冬至之后 105 天即为清明节气,这就是标准的节气定气法则,至于当代公历为求简便则总是以 4 月 5 日为清明节,这就是简化日常生活与约定俗成了,而最近这些年搞的黄金周的清明假,那就完全不是那回事,所有黄金周都可以被纯粹地视为闹剧了:春运还不够,一定要全年随时都搞成春运才行。是吧,这也是拉动内需的一个关键行为了。
好,最后,公平一点的说,其它的几种说法,包括立冬为首,立春为首的一些说法,都是某种妥协。比方说习惯上总会认为春天是一年之首,那立春就应该是二十四节气之首。然而研究农历演算方法就能知道,第一天,还的是冬至。古代曾有以夏至为首的演算方法,不外乎是测得日影最短的那一天为夏至,这种方法还比较有道理。但立春就谈不上道理,只能说是习俗的某种妥协而已。
前两年听说有学者质疑以春季立春为首,大寒为尾的排序方法是错误的。我也只能是笑了。春季立春从来都不是首啊。所谓的春季立春为首的说法,只是历法上的一个排列,历法要照顾农业习作,节气歌要利于传唱和记忆,这些地方没有排序这一说法。
作为一个定论,严谨地表述,那么只有一个地方有所谓的节气排序,那就是农历演算、制定与颁布的标准,古代以及今日国标都明确了第一个节气为冬至,据此定气定年。
所以这些学者真是不学无术,好像能提出问题就是专家了。这也太容易了。
后记
我经常能看到各种荒谬的言论,然而当然不可能事事都去拨乱反正。正相反,我才不会去管呢。只不过有时会不能忍,又正巧我觅得一点点空闲时间,那就会随便敲打下,一般来说以一小时为限,如果太紧张,那就如本文一样不求精确、不加考证也不列举参考文献,事实上懂的都懂,列参考文献那真不是人干的活,是吧。
后后
那就这样。发了。
后后后
我以前曾将收集的历法知识汇编罗列出来,感兴趣的朋友可以试试站内搜“lunar”或者“历法”去瞅瞅。
但,同样地,并非专业那么严谨,只能说八九不离十吧,比较于其他外面的很多文章,至少也是负责任的那种了。
可惜不能疯狂暗示点赞还是三连啥的。
那就真的就这样了。
:end:
]]>结构体中的成员的对齐优化,是 lint 中一个含混的提法。初学者尤其是 C++ 程序员会对此迷惑,因为 string 就是一个指针,一个指针在结构体中当然是字长对齐的呀。
1
2
3
4
5
6
type MyStruct struct { //nolint:govet //can be reordered
id uint64
required string
note *string
}
这个结构体(去掉 nolint 宣告后)会得到 golangci-lint govet filedalignment 的报告:
1
fieldalignment: struct with 32 pointer bytes could be 16 (govet)
实际上呢,fieldalignment 这个家伙并不是真的在分析结构体的成员变量是不是有效地字长对齐了。它做的事是研究成员变量的排列顺序是否有利于 GC 扫描依赖关系。
所以解决的方法就很清楚了,将带有指针的字段尽量提前,隐式指针的类型(例如 string,slice,array,map 等)也如此,那么 GC 只需要扫描结构体的前面若干字节就能判断依赖关系了。
相反地,如果首先摆放占地面积很大的字段,那么 GC 可能不得不需要先遍历这块空间后才能发现 b 是需要登记一个引用计数的:
1
2
3
4
5
// GC will scan all 808 bytes.
type gcNotOptimized struct {
a [100]int64
b *int64
}
所以它的改进应该是:
1
2
3
4
5
// GC will scan only 8 bytes.
type gcNotOptimized struct {
b *int64
a [100]int64
}
严格地说,GC 的确不喜欢 [100]int64 这样的东西,这更是因为单个结构体占据空间太大了,这样的代码应该从设计上拆分,使用小巧的结构体才是被推荐的。
当然这个例子纯属故意,仅作示意,不够严谨之处就算了。
而前面的例子 MyStruct 应该改写为:
1
2
3
4
5
type MyStruct struct {
note *string // 指针优先
required string // 隐式指针次之
id uint64
}
关于 fieldalignment 还有一些可以讨论的,所以下次单独一篇聊一聊吧。呐,现在就是下一次了。
下面才是新的。
由于核心要素在于让 GC 扫描器针对 struct 从头开始扫描字段时尽可能少遍历内存块,那就要将指针型字段尽可能前置,方便扫描器只要检视这部分字段就可以了,然后占地面积少的小字段尽可能靠前,让扫描器必须检视的内存块的 Size 也足够小。
不过实际上 fieldalignment 的源码揭示的规则相当复杂,很难对其进行简化和归纳。
所以我们只能采用比较笨的办法,罗列出如下的顺序原则供参考:
struct{}
在语言规范层面被设计为 0 字节占用,而不是像结构那样被当作隐式指针。它需要被置于结构体最前端。总的来说,字段摆放和 C++ 惯例上的字长对齐是一点关系也没有的。而且所谓的不合格的顺序,其实和对齐之后节约空间也是一点关系都木有。
另外,C++ 程序员往往习惯于将字段按照小的功能组来分组排列,但这是没意义的,因为顺序首先需要屈服于 GC 的喜好。所以在 Golang 里面强迫症患者都得死,很可悲。
下面是不合适示例及其解决:
1
2
3
4
5
6
7
8
9
10
type MyStruct struct { //nolint:govet //can be reordered
id uint64
required string
note *string
}
type testCase struct { //nolint:govet //can be reordered
given float64
expect string
}
改正后的写法如下:
1
2
3
4
5
6
7
8
9
10
type MyStruct struct {
note *string
required string
id uint64
}
type testCase struct {
expect string
given float64
}
实际上也有一个工具可以自动更正你的代码中的这些问题。
可以编译和安装这个工具:
1
go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest
然后你肯定已经将 ~/go/bin
加入到环境变量 PATH
之中了。所以:
1
fieldalignment -fix <package_path>
就能够自动解决问题了。所有的结构体都会被重新摆布一遍,依据前面我们所提到过的那些基本准则。
然后,这个工具也有一个关键的短板,让人欲罢不能:它会在重新排布结构体成员的时候,将所有空行、注释通通删去。
这就要了老命了。
所以有时候,你应该 git commit 一次,然后用一下这个工具,然后通过 git diff 来 review 它所做的变更,然后进行若干后处理。
更好的办法是,参考前文所叙述的原则上的顺序,你可以自行手工排布,在编码的时候直接就消除一切相关警告。那就不会有问题了。
自从从某人那边了解到 golangci-lint 之后,我就再也没用过 go lint 了。必须承认 golangci-lint 的可用性要优秀的多。当然有时候(特别是项目足够大时)它占用的 CPU 也太高了,以至于有时候必须对其进行限制。
在 Golang Project 的根目录放一个 .gilangci.yaml 就能在整个项目中应用其中使能的 validdators。
fieldalignment 是隶属于 govet 的一个子功能,在 .golangci.yaml 中可以这样启用它:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
linters:
disable-all: true
enable:
- govet
fast: false
linters-settings:
govet:
# report about shadowed variables
check-shadowing: false
fast: false
# disable:
# - fieldalignment # I'm ok to waste some bytes
enable:
- fieldalignment
反之就取消 disable 注释然后去掉 enable 子句。
如果感兴趣官方是怎么做的,可以研究源码:
fieldalignment
code https://cs.opensource.google/go/x/tools/+/refs/tags/v0.1.7:go/analysis/passes/fieldalignment/fieldalignment.go
golang 的源码也可以读读,可以从这里开始:
https://github.com/golang/go/blob/master/src/runtime/mgcmark.go
一般来说我并不推荐。
超大型项目不是那么好读的,也不一定有用处。特别是遇到活跃的项目都活跃的部位时更是很不值得。
放飞自我时间到!
我这次很沉稳,啥也不说。
稍微列出一点,
更多的自己搜吧
本文论及的排列顺序,纯属个人理解,并未得到相关官方认可。
参考本文所引起的后果辄非作者所控。
如有必要,请以 fieldalignment -fix
的结论为准。
🔚
]]>不是说因为内存、SSD 现在多便宜啊就说嫩个大数组搞个循环有啥关系啊,能够用 hashmap 的地方还是应该采用几乎直接命中的方案而不是遍历整个集合的策略的。
这原本该是一种礼貌的。
可惜的是完全无知的人意识不到那里不是荒原,也不应该因为是荒原就可以乱扔垃圾对不。
Profiling and Tune/Optimizing 是编程中例行的研究手段,用时髦的话来说就是这是一种洞见能力,能够洞察代码中的不优雅之处以便予以改正。
实际上很多编程惯例都是有章可循的,直接就能写出来,而非需要时候 pprof 找到瓶颈才来修订代码。
所以下面总结一些惯例出来,基本上到处都有不同形式地提过、论述过、讲解过这些手段,不过我这边就会加上我的思考来帮助你知其然且知其所以然,甚而至于能够推及未有罗列的其它场景。
例如高效的连接字符串和 rune 字符,也有其惯用法的,和下文的方案大同小异,但是就不必单开小节来讲了。
真要讲起来,区区一篇文章怎么可能?
现在的任何一个所谓的优化,背后都是数十年来计算机系统演进历史,PL 演进历史的浓缩。小到一个 8 bytes 对齐后边就涉及得到 CPU 的整个演进历史。
所以推而广之、举一反三的能力你必须有。
好,下面一一列举一下。
和一般的 C++ 老手的直觉完全不同,给函数传递结构体在 C++ 里面是很傻的行为,因为这带来隐含的结构体平凡拷贝(Trivial Copy,按位按字节复制结构体本身,对于指针成员仅复制指针本身,而不制作指针所指向的对象的副本),所以老式的 C++ 程序员会记得传递结构体的指针,而善用新风格的则更愿意使用结构体的引用,特别是 const 方式的引用。例如:
1
2
3
4
5
6
#include <string>
#include <regex>
void replace(std::string const& str, std::string const& find, std::string const& repl) std::string {
return std::regex_replace(str, std::regex(find), repl);
}
上面代码仅作示意,未经过编译运行和测试,可能存在记忆混淆带来的量子扰乱。
在 Golang 中,情况恰好相反。首先一个不同在于,Golang 传递一个结构体时也不过就是传递其实际内存地址1,这相当于是一个指针了,也并不包含隐含的平凡拷贝行为。其次一个问题在于 GC。在绝大多数情况下,由于 GC 的逃逸分析带来的副作用,所以传递指针作为函数参数会带来额外的逃逸分析,即需要跳进到指针所指对象结构体中扫描逐个成员以求取其引用计数。这使得 Golang 指针比较结构体本身需要更多算力和资源作为代价。
以前我写过一篇文章 Golang - 关于指针与性能 宣称说能用指针的时候一律使用指针。那话不能算错,因为场景不同。在常规编程领域,你首先需要关注自己的编程目标的达成,即代码逻辑和功能正确实现。使用指针会为你消除较多隐式的坑。例如函数传递结构体的代价在于这个结构体在函数体中的修改是不能反应到调用者的,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main
type A struct {
F int
}
func Work(a A) {
a.F = a.F + 1
}
func main() {
var a = A{3}
Work(a)
println(a.F)
}
// Output:
// 3
这个结果理所当然。不过 Golang 初学者会因为无意识忽略而忘记检查,此时他们的原意可能就被坑掉了。
或者说,那篇文章中的提法不免有点片面。
不过倒也无所谓。
还是上面的示例代码,问题在 Work 函数这里就复杂化了。
此时,Work 函数中结构体由于成员被修改,因而产生了一个隐式的副本!
这不但带来了额外的一次拷贝,实际上也增加了 GC 分析的工作量2。可以说不但没能获得结构体传输相比于指针参数所带来的益处,还变本加厉地增加了开销。改正的办法是采用指针作为参数3。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main
type A struct {
F int
}
func Work(a A) {
a.F = a.F + 1
println(a.F, &a.F)
}
func Work2(a &A) {
a.F = a.F + 1
println(a.F, &a.F)
}
func main() {
var a = A{3}
Work(a)
println(a.F, &a.F)
Work2(&a)
}
// Output (as a ref):
// 4 0xc00003e718
// 3 0xc00003e720
// 4 0xc00003e720
多么可悲啊。
或者说,Golang 是多么可怜的一种开发语言啊。但凡你的计组学的差了点,也都会在 Go 上面死得花样地难看。
此外,
有的场景是必须指针才行的。
下面就略作探讨,也算是补齐以前旧文章对指针的未竟之处吧。
首先要知道的是,接收者只能是指针才能修改成员值的。
1
2
3
4
5
6
7
8
type Int int
func (s Int) Set(i int) { s = i } // Wrong
func (s *Int) Set(i int) { *s = Int(i) } // It worked.
var i Int = 7
i.Set(8)
println(i)
Line 3 的写法也是一个坑,它能编译,能运行,没有任何错误,只有一个问题,本尊的值不会被 Set(i) 所修改。
要想达到目的,只能采用指针版本的 receiver(如同 Line 4 那样)。
进一步地,slice 场景也是类似的:你为一个 Slice 声明了别名类型,然后想要为其配备一些功能,例如对给出的参数进行装饰后再设置到 Slice 中,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
type IS []int
func (s IS) Set(args ...int) {
for _,v:=range args {
s = append(s, v+1)
}
}
var is = IS{2, 3}
is.Set(1, 2)
fmt.Println(is)
// Outputs:
// [2, 3]
正确的方法是采用指针版本4],然后书写方式上有一点点小技巧,即通过 *s
来访问 Slice 本体。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
type IS []int
func (s *IS) Set(args ...int) {
for _, v := range args {
*s = append(*s, v+1)
}
}
var is = IS{2, 3}
is.Set(1, 2)
fmt.Println(is)
// Outputs:
// [2, 3, 2, 3]
再进一步,把结构体用作 map value 时,你有可能无法在迭代中修改结构体成员值。例如:
1
2
3
4
5
6
7
8
9
10
var m1 = map[int]A{1: {4}}
for k, v := range m1 {
if k == 1 {
v.F *= 2
}
}
fmt.Printf("%+v", m1[1])
// Output:
// &{F:4}
解决的办法是指针5:
1
2
3
4
5
6
7
8
9
10
var m1 = map[int]*A{1: {4}}
for k, v := range m1 {
if k == 1 {
v.F *= 2
}
}
fmt.Printf("%+v", m1[1])
// Output:
// &{F:8}
当然,本条目的主题还是优先使用 copy 引用以达到性能优化的目的。例如使用 bytes.Buffer,而不是使用 *bytes.Buffer,尽管书写时两者常常没有使用上的区别67。
前面提到的函数传参使用结构体而不是结构体的指针,也是为了使用 copy 引用。都是为了有利于 gc 分析。
然后前面也提到旧文章 Golang - 关于指针与性能 宣称函数传参使用结构体指针才是推荐的,因为这可以避免结构体的复制。这一点,本文中散乱在各种的解说能够给你全貌:
是的,指针避免结构体复制,但复制并不会总是发生8。除非你在函数体中修改了结构体成员,导致 golang 不得不为其创建一个本地副本,那就存在额外代价的可能9。其它情况下,是否总是复制我暂未有定论,可能需要追溯 golang 规范或者源代码才能确定。姑且可以认为是总是会发生吧。
指针参数带来额外的 GC 逃逸分析开销,究竟大不大、值不值,那就是一个神学问题。怎么办,pprof 具体分析和判断。其实普通情况下不至于为此大动干戈。
如果结构体超级大,那么 copy 引用在 GC 上的收益可能抵不过指针传递,那么此时可能你还是应该使用指针传参才对。
在 go 最新的几个版本里,有各种各样的微型优化。例如小于 16 bytes 的 string 将不会发生内存分配,等等。
我想说 shit。虽然这种特性好得很,但是太随意了。
一个善良的类库可能广泛地使用很多小对象,并且每个小对象都有配套的 new 函数。然后下面的代码就显得很正常:
1
2
3
4
5
6
type Data struct {
config *DataConfig
}
cfg := NewDataConfig(true, 1, "slow")
data = NewData(cfg)
这并没有什么不妥。但是它的性能弱于:
1
2
3
4
5
6
7
data = &Data{
config: &DataConfig{
enabled: true,
count: 1,
mode: "slow",
}
}
如果有必要且有能力,那么就不要分离地构造这些所属的小型对象,而是将其组合在一起,一次性地完成。
结构体中的成员的对齐优化,是 lint 中一个含混的提法。初学者尤其是 C++ 程序员会对此迷惑,因为 string 就是一个指针,一个指针在结构体中当然是字长对齐的呀。
1
2
3
4
5
6
type MyStruct struct { //nolint:govet //can be reordered
id uint64
required string
note *string
}
这个结构体(去掉 nolint 宣告后)会得到 golangci-lint govet filedalignment 的报告:
1
fieldalignment: struct with 32 pointer bytes could be 16 (govet)
实际上呢,fieldalignment 这个家伙并不是真的在分析结构体的成员变量是不是有效地字长对齐了。它做的事是研究成员变量的排列顺序是否有利于 GC 扫描依赖关系。
所以解决的方法就很清楚了,将带有指针的字段尽量提前,隐式指针的类型(例如 string,slice,array,map 等)也如此,那么 GC 只需要扫描结构体的前面若干字节就能判断依赖关系了。
相反地,如果首先摆放占地面积很大的字段,那么 GC 可能不得不需要先遍历这块空间后才能发现 b 是需要登记一个引用计数的:
1
2
3
4
5
// GC will scan all 808 bytes.
type gcNotOptimized struct {
a [100]int64
b *int64
}
所以它的改进应该是:
1
2
3
4
5
// GC will scan only 8 bytes.
type gcNotOptimized struct {
b *int64
a [100]int64
}
而前面的例子 MyStruct 应该改写为:
1
2
3
4
5
type MyStruct struct {
note *string // 指针优先
required string // 隐式指针次之
id uint64
}
关于 fieldalignment 还有一些可以讨论的,或者下次单独一篇聊一聊吧。
前面的字符串连接优化提示中已经讨论过 []byte 的预分配了。
但这个技巧还在广泛的场景中可被应用。
1
2
3
4
5
6
7
8
9
10
11
type X struct {
buf []byte
bufArray [16]byte // buf usually does not grow beyond 16 bytes.
}
func MakeX() *X {
x := &X{}
// Preinitialize buf with the backing array.
x.buf = x.bufArray[:0]
return x
}
这里,使用 [16]byte
尤其是 CPU 字长对齐或者 CPU 流水线长对齐,或者内存总线字长对齐,或者内存页帧长度对齐(对于不同的目标的对象,常常有 16 字节,4K 字节,16K 字节等等不同的页帧或者类似页帧的对齐尺度,具体语言具体目标设备具体研究),全都是好的想法,并且能够有效果。这方面的细节进一步的研究请忘高性能编程、无锁编程、高频交易方面深入。
另一个案例是抽取栈帧:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func getpc(skip int) (pc uintptr) {
var pcs [1]uintptr
runtime.Callers(skip+1, pcs[:])
pc = pcs[0]
return
}
func getpcsource(pc uintptr) Source {
frames := runtime.CallersFrames([]uintptr{pc})
frame, _ := frames.Next()
return Source{
Function: frame.Function,
File: checkpath(frame.File),
Line: frame.Line,
}
}
这里的 [1]uintptr 是最优化的抽取具体一帧的做法。
当然,接下来的 getpcsource 函数也是实现的极为精简的了。这一套实现比较于 logrus 的逐帧遍历,分析 package name 来跳过 logrus 自身代码寻找到 caller 的算法要高效的多了。
这个思想也在于减少对象分配,减少 gc 综合压力。例如:
1
2
3
4
5
6
for k, v := range m {
k, v = k, v
go func() {
// use k and v
}()
}
不得不说荒唐镜啊。
对于这多个局部变量的捕获,更好的办法是使用一个临时 struct:
1
2
3
4
5
6
for k, v := range m {
x := struct{ k, v string }{k, v} // copy for capturing by the goroutine
go func() {
// use x.k and x.v
}()
}
而且也可以传入而不是捕俘:
1
2
3
4
5
for k, v := range m {
go func(k, v string) {
// use k and v
}(k, v)
}
此外,使用多变量同时赋值,用 var() 聚合声明,也都有利于 gc 分析的好处,尽管它们的好处微不足道:
1
2
3
4
5
6
var k, v = 1, "str"
var (
k = 1
v = newData()
)
代码中经常会发生连接字符串的需求,这通常可以
+
运算符我这里不给出分析或者 bench 证明,那有点小题大做(而且很多人列举了 repo 或者 gist 来证明过了)。所以省力一点出结论,上述四种办法的常规写法都不是特别恰当的高效连接方案。例如下面这些写法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var s1 = "This solution"
var s2 = " is pretty good."
var s3 = s1 + s2
var s4
// fmt.Sprintf 和 + 难分高下,除非具体场景 profiling
s4 = fmt.Sprintf("%v%v", s1, s2)
s4 = fmt.Sprintf("%s%s", s1, s2) // 优于 %v
var s5 strings.Builder
s5.WriteString(s1)
s5.WriteString(s2)
var s6 = s5.String()
var s7 bytes.Buffer
s7.WriteString(s1)
s7.WriteString(s2)
var s8 = s7.String()
在通用场景中,bytes.Buffer 比较 strings.Builder 有微小的优势,但有时候则会更差。但两者都比 fmt 或者 + 更有效率。
然而,更好的方案是如下两个:
1
2
3
4
buf := []byte("Size: ")
buf = strconv.AppendInt(buf, 85, 10)
buf = append(buf, " MB."...)
s := string(buf)
效能更高的:
1
2
3
4
5
6
roughSize = (6+2+4)*3 // 粗略估算,至少大于实际需要的串长
buf = make([]byte, 0, roughSize)
buf = append(buf, "Size: "...)
buf = strconv.AppendInt(buf, 85, 10)
buf = append(buf, " MB."...)
s := string(buf)
roughSize 如果能够提前精确计算那就更好。如果不能,那么也应保证至少会大于实际需要的串长。由于 Golang 字符串采用 UTF-8 格式,这种编码的特点是对于全部字平面来讲平均长度为 3 bytes(粗略地),所以 roughSize 需要乘以 3 来适应国际化场景。如果使用 []rune 则没有这样的忧虑。但是一切均需以 profiling 为话事者,虽然大多数情况下,两者的区别不值得要跑几遍 progiliing 来做 deed hard 优化。
这里的核心思想在于提前一次性分配所需空间,而不是在需要时临时扩充,也就是提前做单次 grow。如果有大量字符串串接的需求,这个思想可以带来显著的收益。
类似地也可以:
1
2
3
4
5
6
7
roughSize = (6+2+4)*3 // 粗略估算,至少大于实际需要的串长
var sb strings.Builder
sb.Grow(roughSize)
buf.WriteString("Size: ")
buf.WriteString(strconv.Itoa(buf, 85, 10))
buf.WriteString(" MB.")
s := string(buf)
它和 append+strconv.AppendXXX 的差距不大,有时候更容易适应复杂场景。
40+ practical string tips (cheat sheet)
就我个人而言呢,这辈子都不可能用 sync.Pool 的。
哈哈玩笑一个。
实际上很多人都推荐使用 sync.Pool 的。这个数据结构原本是用于制作一个临时对象池,减少临时对象的分配。这种 Pattern 的本意是用于比方说数据库连接对象池,不必在需要连接时才向数据库发起连接请求,而是提前准备就绪,立即取用。然后现实生活中,它被 gophers 们用于提前分配内存,需要的时候直接取用,目的在于减少高频交易时分配内存的开销,进一步由于不必释放内存,还降低了对 GC 的请求频度,缓解了 GC 压力过重导致的 CPU High Usages 以及 app 的瞬时停止应答(由于算力被用于 GC 回收)问题。
该说什么呢?
这倒也算不得误用滥用,反而可能是一种合理的选择。
而且 syncPool 还有一个独特的优势,它是原子的,被用在并发编程中时省却很多“心智负担”。
我个人之所以很少用到它,纯粹是很多时候无法忍受泛型缺失或者阉割泛型的丑陋却又不愿意等而次之选择一个次优品作为代替。
fmtPrintf 基本上是代价较为昂贵的东西,它的 %v
很好用,但是会用到 reflect,那更是个性能坑货。
json 之类的东西,yaml 或者 toml 等等,全都代价昂贵。
磁盘 I/O 尽可能使用 Buffered I/O,这个带来的益处好过调优内存。
采用埋点日志方案(参考我的 在 Go 中实现更好的埋点日志功能 ),编译 release 版本时关掉日志,可以大幅度提升性能。如果需要运行日志,在 release 版本中将其改为异步写入磁盘文件,这也是一种选项。
避免使用 any。如果有可能,则使用确切的数据类型。
不使用 reflect。
不使用 reflect,有时候可能只是一种奢望。大多数隐含处理通用数据类型的有用的工具,全都逃脱不了 reflect 提供的能力。例如 fmt.Printf(“%v”, v),json 库,deepcopy 库,config center/store,pretty print 等等。
至于泛型,没有结构体成员(函数)的独立的泛型能力,现在市面上提供的各种泛型库常常只能沦为玩具。你还是不得不捡起 any 和 reflect 来完成自己的编码工作。
在 Goland 中 Bench with Profiling 非常容易,也很容易查看 pprof 火焰图,以及 Allocated Objects 统计表。
在一个 Test/Bench 函数的左侧边栏上直接做带有内存分配分析的 pprof,直接就可以。
vscode golang 在 run a main function, test with profiling 上没有这些舒适度。
vscode 也没有 run config,saved run/debug config, pinned,这些都是 Goland 的优势。
所以我都是被养刁了。
我本来是个很纯粹的 Bash 党。你可能知道我干过不少 DevOps 的工作,甚至还维护了一个 bash 函数集合与基本框架 bash.sh。
所以以命令行方式使用 pprof 我也很熟悉的,奈何,少折腾也是好事。少数最关键的 pprof 命令行用法,可以参考我的旧文章 Golang Profiling: 关于 pprof ,它不是参考手册,顶多算个 cheetsheet,临时取用还是方便的。
放飞自我时间到!
前面提到我不爱用 syncPool。
C++ 在这方面简直完美。就是说,C++ 随手撸个 connectPool 都可以性能、优雅尽皆上佳的程度,程序员有充分的操控能力。同时,还是类型完美泛化的。
强类型的泛化,采用 C++ 的零抽象+编译期计算思路,可以得到最佳性能,还可以规避了类似于 Golang interface{} 的封箱开箱代价。C# 如此的完美,但在基本类型上的封箱开箱潜在代价也还是令人不适。但我没有研究过 dotnetCore 3+ 以及 dotnet 5\6\7 在各种细节上的优化力度,就不多说了。而 Golang 的泛型,没有匿名函数泛化,以及方法函数泛化,那还能做什么?我的工程只需要一个 Pool[T] 就够了么?所以现在的 Golang 虽说有了很多泛型库,但其实就是个玩具,做做演示还可以,工程中实用,也就只能 minmax 了。什么时候标准库泛型化了,才能谈工程实作。
所以我的选择是这么高频的交易那就还是 C++ 吧,如果还不满意,那就 C 吧。什么 Golang,Python 都是弱爆了被吊打的家伙什。
至于 Rust,你可能注意到了我从来不写 Rust 的技术相关内容,因为这也是个丑陋到爆的家伙呀,而且我最讨厌有人把我当傻瓜一副为我好的嘴脸了。
回忆以前用了好多年才学会 Java,后来我总结,不是我太笨,而是太笨的语言我确实从心底里抵触它所以学不会,;D。这倒不是我为自己开脱,真哒!实在是完美主义者必会如此,忍不下去怎么办呢对不对。我一直也有计划想做一门自己专属的语言(C++ 我不满的地方也多了去了,特别是现在这些特性都那么异形,lambda 语法如此丑陋,Shit,后来我自己设想 C++ 增加 lambfa 也没想出来漂亮超过 Kotlin 的语法,也就作罢了),哎,可惜这种工程真的太庞大,又难以把这想法拿来合作,众口难调,那时候做出来的新 PL 还是我想要的那个吗。只能搁置下去了。
然后回忆学了好几年 Rust,也不能说不会,但是衷心地难受啊,循环引用搞死人。我不能像那样地生活,我确信是如此的。我学计算机的初心是啥哩,不就是能够操控计算机吗,整出个语言来操控我、把我当作初中二年级般的呵护,我谢谢你吖,那就还是算了吧。
前面也说到了若干 C++ 程序员在 Golang 上的难看的死相。
我就是一路踩坑上来的。
我一直在筹备想要把 cmdr 重写一遍,把那些拙笨的细节给抹了去。然而因为一些个原因,这两年没有什么整块的时间能够静心编码,就一直搁置。所幸功能上 cmdr 还是有自己的特点的。而且其实即使未来升级 v2 了,那些 ugly 的代码也消失不了了,它们都成为黑历史隐藏在平时不可见之处,稍稍用心翻一翻依然能够抽出来。说到这里还要说点额外的话,我都不担心别人翻我的黑历史,毕竟我的项目没名气,我也不是什么有名气的人物,我也不喜欢有名气,我更喜欢放一堆人中间找不出来的效果,那谁会来专门跟我过不去呢是不是,这成本也不划算啊。然而,AI 和你讲什么成本?!它会把我的代码从仓库里翻检出来反反复复地研究、计算,some day 在某本 AI 教科书里面就能作为经典反面案例给公示出来,TM 下面还会用小字体给出一个链接,直达我的 GH repo source code source line。哎嘛的,越想越不舒服斯基。
由此,我也会对 Dave 等真正的大佬佩服的紧,他们给出的代码总是能够成为正面范本,从未有什么过了两年我懂的又多了,代码写的更好了的说法。换句话说,他们的代码水平没有学习和提升一说,一直都在巅峰高位。
这是不是也挺恐怖的。
都说到这里了,继续对 AI 嗤之以鼻。讲真,我不是一个人,我的眼里市面上的 AI 能叫什么 AI,全都是傻子。70~90年代理论界和工业界都比较淳朴,他们讲人工智能时,会谈论等效智力,所以强人工智能若能达到 4 岁小孩水平那就足够 OK 了。但当下的 AI 要谈智力岂非可笑。所以现在资本的眼光又在看 AGI 了。其实都是无聊的东西。按照我个人私下的看法,冯氏计算体系下面机器要诞生智慧,应该是不可能的事情。未来,甚至也许就是明年,不同的计算模式下诞生真正的人工智能说不定就会发生,但那肯定是不同的系统模式和计算模式了。我很久以前从 Prolog 开始思考如何达成和创造机器智能,但数度思考失败的结论就是上面的看法,现在的 CPU、内存、外存 这样的组合,通过计算来产生智慧,这在逻辑上、理论上都是不可行的。智慧,需要一种无限递归且能够自动中断的能力,以及一种无限发散然后自动归纳的能力,这些能力是先有计算体系所难以实现的。
收!
所以,旧文章如果有错误,我也不订正了,顶多写篇新的。
但是,两天后,本文我涂抹了一下,因为有的提法有点粗糙不够精确,难免显得太弱鸡,所以你懂的,我还是粉饰了少少。
别说我不是什么大佬。就算真大佬犯错被喷的也多的是,我错错错点什么的又有什么了。
一笑而过。
稍微列出一点,
更多的自己搜吧
🔚
此说法较为粗糙,实际情况非常复杂。从总体理解角度,你需要知道传递一个结构体代表着复制行为,但由于优化器的存在,复制行为可能并不一定总是会发生。 ↩
是否是因此产生的额外的工作量难以定论。但是一定会因此产生临时副本供子函数修改是无疑议的 ↩
未必只有这种方法,行文从简 ↩
更好的、更弹性的做法是使用 A struct { Items []int }
包装起来 ↩
或者也可以使用 m[k] = v
的方法去强行修订 value,但好不好就难以定论了 ↩
如前文所述,如果传递给子函数,那么也只能是指针形式了 ↩
或者是包裹在map之中,或者放在一个 struct 里进行传递,然而实际传递的 map 是一种隐式指针,或者 *struct 也还是需要以指针形式传参 ↩
取决于优化器的优化深度 ↩
这未必是真的,也未必产生额外代价,但是作为一个理解可以暂时放在这里 ↩