线程安全的对象生命周期管理

析构函数&多线程

多线程共享对象,对象的线程安全特性可以由对象内的同步原语保护。但是对象的析构没法通过内部的同步原语保护。(猜测要通过外部的同步原语保护)

比如如何保证在执行成员函数期间,对象不会被其他线程析构。

线程安全

线程安全的类满足如下三个条件:

  • 多线程可以同时访问,表现正常。
  • 无论这些线程的执行顺序如何交织。
  • 调用端无需同步操作。

C++大部分类,string,vector都不是线程安全的,也可以理解,毕竟需要线程安全的时间也不多,没必要增加额外的开销。

对象构造&线程安全

如何保证对象构造的线程安全:在构造期间不要泄露this指针。

可以使用两段式构造。

对象销毁&线程安全

很难解决的问题。文章从面对对象程序设计的角度提出了一些问题。

对象的关系包括三种:

  • composition(组合):如struct A{struct B b;};b的生命周期由A决定,A对象被析构时,b也被析构。
  • association(关联):在形式上与下面一条很类似,生命周期独立。
  • aggregation(聚合):如struct A{struct B* b};b,生命周期独立。如果b被释放,则会面临竞态条件。

shared_ptr,循环引用导致两者都没办法被释放。

空悬指针:指向已经被释放内存的指针,比如a,b同时指向一段内存,但是a释放了该段内存。

shared_ptr

传递原始指针简直就是一个灾难,前者没法判断后者对指针的操作,后者也没办法判断前者对指针的操作。

shared_ptr是强引用,会增加对象的引用,而weak_ptr不会,他只想想知道对象是否存在。

使用智能指针通常可以解决如下问题:

  • 缓冲区溢出:可以自动记录缓冲区的长度,通过成员函数访问
  • 空悬指针:用智能指针就不空悬了
  • 重复释放/内存泄漏
  • 不配对的new/delete

这两个智能指针都是值语意,不会有如下写法:

1
auto ptr = new shared_ptr<Foo>(new Foo);//谁能干出这种事啊?

shared_ptr的线程安全

shared_ptr的内部数据包含计数的地址和内存区域的地址,包含两个值,多线程同时读写一个shared_ptr对象时,可能导致错误。——即shared_ptr对象本身不是线程安全的。

对shared_ptr指向的内存区域也不一定是内存安全的,需要程序员包含。

使用shared_ptr时,只能保证对引用计数的更新是线程安全的。

shared_ptr的陷阱

  1. 使用shared_ptr会导致引用计数无法归零。

书上的一个例子,observer在observable中注册了一个对自己(observer)的shared_ptr, 当observer被析构时,当然也希望observable中的shared_ptr被删除。

一种办法是在在observer中的析构函数中调用observerable的取消注册函数,这本身就是一个悖论,因为只有在引用计数为0才会执行析构函数,而observable中保留着引用,那么引用用于不会归零,也就导致永远都不会调用析构函数。

  1. 可以使用const &来传递shared_ptr, 来减小开销。

  2. RAII handle。为了避免循环引用,对象的所有者应该使用shared_ptr, 而一些不太需要对象的需要使用weak_ptr。

对象池

线程同步

四项原则:

  • 尽量少共享对象,如果要暴露,先考虑immutable对象,其次才是可以修改的对象,但需要充分的同步措施来保护他。
  • 使用高级的并发编程构件:TaskQueue、Producer-Consumer Queue。
  • 最后不得已才使用底层同步原语,只使用非递归的互斥器和条件变量,慎用读写锁,不用信号量
  • 除了使用原子操作,不要使用无锁代码, 不要使用内核级同步原语。

仔细想想,还是1和2方面讷

互斥器

互斥器保护了临界区,基本原则:

  • 用RAII封装mutex。
  • 只用不可重入的mutex。
  • 使用guard。
  • 每次构造guard对象是,思考一路上已经持有的锁,防止加锁顺序不同而死锁。看调用栈就可以分析用锁的情况。

次要原则包括:

  • 不使用跨进程的mutex,进程间通信只使用sockets。
  • 必要的时候使用PTHREAD_MUTEX_ERRORCHECK来排错。
只使用非递归的mutex

使用非递归锁可以把程序的逻辑错误暴露出来。

gdb 使用thread apply all bt打印所有栈的信息

死锁

条件变量(管程)

  1. 必须和mutex一起使用
  2. 在mutex上锁的时候才能调用wait
  3. 把布尔条件和wait放到while循环中

条件变量是非常底层的同步原语,很少直接使用,一般使用它实现的高层的同步措施。如BlockingQueue或CountDownLatch。

不要用读写锁和信号量

封装MutexLock、MutexLockGuard、Condition

利用shared_ptr实现copy-on-write

利用shared_ptr替代读写锁,shared_ptr本身包含计数,可以代替信号量的作用,shared_ptr引用为1时,写进程可以进行写,如果引用大于1时,此时其他使用该引用的进程。

多线程服务器

单线程服务器的编程模型

常用的模式:non-blocking IO + IO multiplexing模式,即Reactor模式。

程序的结构:事件循环,以事件驱动+事件回调的方式实现业务逻辑。

缺点:回调函数应该是非阻塞的。

多线程服务器编程模型

one loop per thread

进程间通信

首选TCP,主要是可以跨主机,改改host:port就能直接用。

多线程服务器的适用场合

必须使用(多进程)单线程的场合
  • 程序可能fork的时候,(多线程程序不应该fork,因为fork之后,只有执行fork的哪个线程占据新进程的资源,其他线程会消失,如果遇到锁,会产生大问题)
  • 限制程序的CPU占用率,单线程即使发生busy-wait,CPU利用率也只有12.5%,而如果是多线程,可能导致多个线程跑满核心。
适用多线程程序的场景

无论是IO bound任务还是CPU bound任务,多线程程序都没有什么性能优势。

  • IO bound,任务的执行速度瓶颈在于IO, IO跑满后,cpu仍然没有达到上限。改为多线程程序当然也不会提升性能。
  • CPU bound,任务的执行速度瓶颈在于CPU。改为多线程程序当然也不会提升性能。

使用多线程的场景:提高响应速度,让IO和计算相互重叠,降低latency。虽然多线程不能提升绝对性能,但能提高平均相对性能。(比如说单线程遇到IO就会陷入系统调用,CPU没有被利用,而多线程则可以利用CPU执行一些其他任务)大致要满足如下条件:

  • 多核
  • 线程间有能修改的共享数据,不能修改的共享数据可以用多进程单线程来实现。

看需不需要共享资源来选择多线程还是多进程,

比如处理IO任务,通常需要从listen线程先worker线程传送文件描述符,传送文件描述符这项操作只能在同进程内操作。所以这个任务适合使用多线程。

比如书上72页的例子,多个任务都需要根据机群的状态来决定处理的措施,这也适合用多线程。

线程的分类:

  • IO线程,线程主循环时IO multiplexing,阻塞的等在select/poll/epoll等系统调用上。
  • 计算线程,这类线程的主循环是blocking queue,阻塞的等在condition variable上,这类线程一般位于thread pool上,一般不涉及IO。
  • 第三方库的线程,比如logging,database connection。
问答

BlockingQueue:

通常工作线程需要往磁盘写log,这本身不属于线程的业务范围,为了提升线程的响应速度,可以将写任务通通交给一个日志线程去做。

线程没法减少工作量,通过调配,能让工期提早结束。

C++多线程系统编程

要注意,程序可能在任何时候中断,在任何时候启动。

基本线程原语的选用

posix函数中最有用的十几个:

  • 线程的创建和等待结束(join)。muduo::Thread
  • mutex的创建、销毁、加锁、解锁。muduo::MutexLock
  • 条件变量的创建、消耗、等待、通知、广播。muduo::Condition。

C/C++系统库的线程安全性

glibc库函数很多都是线程安全的,但是将多个库函数组合起来就不一定了。(比如先lseek在read,这之间可能被第三者打断)

C++标准库和string都不是线程安全的。

C++标准库大部分泛型算法是线程安全的,因为他们是无状态的。

iostream也不是线程安全的。因为

<< "Now is" << time(NULL);```等于如下两个函数调用
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

```cout.operator<<("Now is").operator(time(NULL));```这之间可能被其他线程打断。

可以改用printf实现线程安全的输出。

> tips:
>
> a();b();这两个函数一起执行不是线程安全的,不代表这两个函数都不是线程安全的。一个函数是不是线程安全的需要看其内部是不是对某个全局可写变量进行了非原子的多次操作。(个人看法)

#### Linux上的线程标识

* 内核标识——tid,通过syscall(SYS_gettid)访问,用于操作系统内核进行线程调度和管理
* POSIX标识,pthread_t, 在不同的平台代表的意义不一致:指针,结构体之类的。glibc的pthreads把pthread_t作为一个结构体指针,指向一块动态分配的内存,这块内存可能被反复使用,所以不能根据pthread_t的值来判断多个线程是否是同一个线程,需要通过pthread_equal来判断。

推荐使用gettid系统调用的返回值作为线程id。

#### 线程的创建和销毁的规则

创建规则:

* 应该精心规划线程数量,库不能偷偷的创建背景线程,这样也会导致没法安全的fork。
* 用相同的方式创建线程。
* 进入main之前不启动线程。如果不这样,创建的子线程可能访问到未初始化的全局变量。
* 线程的创建最好能在初始化阶段全部完成。

线程的销毁形式:

* 自然死亡——从线程主函数返回
* 非自然死亡——主函数抛出异常或者触发segfault信号等。
* 自杀——线程调用`pthread_exit()`
* 他杀——其他线程调用pthread_cancel来终止某个线程

**避免他杀**:这会导致锁出现很多bug。

如果想要强行终止一个耗时很长的任务,可以考虑为该任务创建一个新的进程,这样kill该进程会安全很多,前提是不使用共享内存或者跨进程的互斥器等。

##### pthred_cancel

如果程序执行到cancellation point这个位置,其他线程调用了pthread_cancel时,该线程可能被终止,通常先会抛出异常,指向 stack unwind。

##### exit不是线程安全的

exit除了终止进程,还会析构全局对象和构造完的静态对象,,这可能产生死锁。

可以使用_exit()代替,他不会析构全局对象,也不会执行其他清理工作。

#### 善用__thread关键词(C++11可以用thread_local替代)

`__thread`是gcc内置的**线程局部存储设施**,可以修饰全局、静态变量,但不能修饰class类型、局部变量等。`__thread`变量的初始化只能用编译期常量。`__thread`是**每一个线程都有一个独立实体**。

{% asset_img image-20250411103123081.png image-20250411103123081 %}

#### 多线程&IO

对于epollfd的修改,可能已经有线程wait在epollfd上,这时某个线程对epollfd进行修改,

可以把对epollfd的操作都放到同一个线程执行。(epollfd如果被多个线程写、监听,可能导致不一致)

尽量让单个单个线程独占的读写一个文件描述符。(pread和pwrite可以从指定的偏移量读写, UDP也可以,因为消息是彼此独立的)

#### 用RAII包装文件描述符

POSIX要求每次打开文件的时候必须使用最小可用的文件描述符号码。

避免多线程对单个文件描述符进行操作。比如A线程读fd6,B线程关闭fd6,C线程打开了新的fd6。这样A会读到错误的文件。

如何解决这个问题:使用RAII,用socket对象包装文件描述符,**所有对文件描述符的操作都通过socket对象进行访问**。(对象在则文件描述符在,对象不在则文件描述符不在)

服务端程序不应该关闭标准输出和标准错误,而是将stdout和stderr重定向到磁盘文件(最好不要是`/dev/null`)。

(客户端关闭连接时,TCPConnection对象不应该立刻被关闭,因为这会导致其中的socket对象被关闭。)

#### RAII和fork

通常使用对象保存资源,**将资源管理和对象生命周期关联起来**(RAII);

> 前面提到:如果一个多线程程序使用了锁,一个锁可能被各种线程持有,假如线程A调用fork,而此时线程B持有锁,那么fork后,其实只fork了调用fork函数的子线程——线程A的代码,子进程将不包含线程B,自然没有办法解锁,导致线程A也没法使用。
>
> 解决方法就是:由调用fork的线程在fork前持有所有锁,然后调用fork后,在父子进程中解开所有锁。(这可以防止锁被其他线程持有,导致解不开锁)。可以利用`pthread_atfork`来实现这个方法。

但是如果程序会fork,这一假设就被破坏了。对象可能构造单次,析构多次。

fork之后,子进程会继承地址空间和文件描述符,因此fork后,动态内存和文件描述符可以正常使用。但子进程不会继承:

* 父进程的内存锁,mlock,mlockall
* 父进程文件锁,fcntl
* 父进程的某些定时器,setitimer,alarm,timer_create

**在编写程序时,是否允许fork需要慎重考虑。**

#### 多线程和fork

多线程程序fork后,子进程就相当于处于**signal handler**中(类似于xv6中的中断一样,中断中可以使用spinlock,但绝对不可以使用sleeplock,但用户空间的锁肯定是sleeplock,**所以所有锁都不应该使用**),将没法调用线程安全的函数(因为其他多半需要加锁,但锁很有可能处于失效状态),只能调用**异步信号安全**的函数。比如不能调用:

* malloc,访问全局状态时需要加锁
* new、map::insert
* 任何pthread函数
* printf函数

> write也可以先文件描述符写数据,但与printf不同的是,他是异步信号安全的。

#### 多线程&signal

信号处理函数中,只能调用**异步信号安全**的函数。

signal分为两类:

* 发送给某一线程,SIGSEGV
* 发送给进程中的任一线程,SIGTERM

多线程程序中,使用signal的第一原则就是不要使用signal,包括

* 不要使用signal作为IPC的手段,
* 不要使用基于signal实现的定时函数,包括`alarm/ualarm/setitimer/timer_create/sleep/usleep`等。
* 不主动处理各种异常信号(SIGTERM、SIGINT等),只用默认语义,结束进程。有一个例外,SIGPIPE,服务器程序通常需要忽略该信号。(因为我们可能往一个已经关闭的连接中写数据,如果不关闭,则可能导致程序终止)。
* 如果没有其他的替代方法,比如需要处理SIGCHLD信号,那么可以把异步信号转换为同步的文件描述符事件。
* 比如在signal handler中往一个特定的pipe写一个字节,在主程序中从pipe读取数据,然后处理。
* 使用signalfd把信号转换为文件描述符事件,从根本上避免使用signal handler。

#### Linux新增系统调用的启示

从Linux内核2.6.27开始,凡是会创建文件描述符的syscall都增加了额外的flags参数,可以直接指定O_NONBLOCK和FD_CLOEXEC。

比如:accept4、eventfd2、inotify_init1、pipe2、signalfd4、timerfd_create。

以下新系统调用可以在创建文件描述符时开启FD_CLOEXEC选项:

dup、epoll_create、socket, 这个标志FD_CLOEXEC可以在程序执行exec时自动关闭这个文件描述符。

上面的八个函数都支持FD_CLOEXEC参数,就是为了避免fork+exec之间的文件描述符泄露。

#### tips:

* 每个线程要有确定的职责,
* 线程之间的交互尽量简单,使用消息队列传递消息,一个线程避免使用两把或更多的锁,

### 高效的多线程日志

日志有两个意思:

* 诊断日志,即log4j、logback等日志库提供的日志功能
* 交易日志,如数据库的write-ahead log,文件系统的journaling等,用于记录状态变更,保证事务的**原子性**。

本文主要针对第一个日志。在生产环境中需要做到Log Everything All The Time。对于关键进程,日志通常需要记录:

* 收到的每个内部消息的id(还可以包括关键字段、长度、hash)。
* 收到的每条外部消息的全文。
* 发出的每个消息的全文,每个消息都有全局唯一的`id`。
* 关键内部状态的变更。

日志库可分为前端(frontend)和后端(backend)两部分,前端是供用户程序使用的接口,并生成日志消息;后端负责将日志写到目的地。

如何将日志数据从多个前端传输到后端是一个典型的多生产者-但消费者问题。

* 对生产者而言,要做到低延迟、低CPU开销、无阻塞
* 对消费者而言,要做到足够大的吞吐量,并占用较少资源。

C++的日志库前端大致有两种API风格:

* C/Java的printf(fmt, ...)风格,例如`log_info("Received %d bytes from %s." len, getClientName().c_str())`;
* C++的stream<<风格,例如`LOG_INFO << "Received" << len << "bytes from" << getClientName();`

muduo日志库是C++ stream风格,这样用起来更自然,不用考虑保持格式字符串与参数类型的一致性。

#### 功能需求

级别:在测试环境使用DEBUG级别的日志,生产环境使用INFO级别的日志。

对于分布式系统的进程而言,日志的目的地只有一个:本地文件。应该避免向网络文件系统写日志,避免故障扩散已经加速带宽消耗。

日志文件可以根据文件大小以及时间进行滚动。

格式:

`logfile_test.20250411-191150.hostname.3605.log`

* logfile_test,是进程名字,通常是main函数argv[0]的basename。
* 时间,可以使用通配符`*.20250411-19*`来选择一段时间的日志。
* 机器的主机名
* 进程id
* 统一后缀

日志文件压缩和归档不是日志库应有的功能,应该交给专门的脚本去做,

日志存在一个问题,程序崩溃前,最后几条日志很可能丢失,解决方法:

* 定期(3秒)将缓冲区的日志消息flush到硬盘
* 每条内存中的日志消息都带有cokkie,其值为某个函数的地址,可以在core dump文件查找cokkie来找到尚未写入磁盘的消息。

日志的默认格式:

{% asset_img image-20250412114806297.png image-20250412114806297 %}

* 每个日志占一行——便于使用awk、sed、grep等工具分析日志,比如`grep -o "2025-03-24T14:22:.." .test.log | sort | uniq -c`

* 时间戳精确到微秒,每个消息都通过gettimeofday来获取当前时间,该函数不涉及系统调用。

* 使用GMT时区,(GMT是本初子午线上的标准时间——唯一,UTC时间以附近的经线确定时间,每15度是一个不同的时区差一个小时,正午确定为12点。比如北京是UTC+8, 东京是UTC+9, 所以北京10点时,东京是11点)



#### 性能需求

写的速度应该足够快,这样占用工作线程时间才足够短。

优化措施:

* 一秒之内的多条日志只初始化微妙部分
* 前四个字段定长,可以避免运行期求字符串长度。
* 线程id预先格式化为字符串
* 每行消息的源文件名使用编译器计算得来的basename。

#### 多线程异步日志

异步日志:一个background负责收集日志,写入日志文件,其他业务线程负责往这个日志线程发送日志消息。

可以使用BlockingQueue,但不用每次产生一条消息都通知接收方。

muduo日志库使用双缓冲技术。这可以避免新建日志消息的时候等待磁盘文件操作。(如果是单缓冲区,向BlockingQueue写新消息时,如果日志线程正在往磁盘写数据,需要等待写完磁盘)前端将日志消息拼成一个大的buffer发给后端,避免了每次有消息就唤醒。即使buffer未满,日志库每3秒也会执行一次上述操作。

AsyncLog基本思路:

前端有两个buffer,可以直接使用,不够时会从系统申请新的buffer,所有写完的buffer都放到队列buffers_中。

后端在获得锁之后,会将前端的两个buffer换为空的buffer,buffers_队列也使用一个新的队列代替,释放锁,再来慢慢的从buffer往文件写数据。

**整个临界区中,对锁的访问是非常快的,基本不涉及任何内存分配任务。**

##### 关键代码

前端速度超过后端:

对于同步IO,会导致前端速度减慢。

对于异步IO,会导致内存堆叠大量数据引发性能问题,可以直接丢弃。

**第二部分**

### muduo网络库

* 基于对象——支持使用对象的编程范式,但不支持继承、多态等特性。
* 面对对象——完整的编程范式——封装、继承、多态。

> 多态
>
> * 编译时多态,实现方式:函数重载,运算符重载,模板
> * 运行时多态,实现方式:虚函数,也可以使用回调函数来模拟。

#### 常见的并发网络程序设计方案

##### 单线程Reactor(方案五)

事件循环所在的线程称为IO线程。(避免在事件循环中执行耗时的操作)

书中给出的`server_basic.cc`程序,网络库负责数据收发,程序只关注业务逻辑。

##### Reactor+线程池(方案八)

> linux高性能服务器认为IO线程不需要从socket读写数据。
>
> 而本书以及一些blog认为IO线程不仅需要检测数据是否到达,还需要读取数据打包给工作线程。

可以将耗时任务交给工作线程处理,避免IO线程阻塞。

{% asset_img image-20250608162719685.png image-20250608162719685 %}

##### Reactor in threads(方案九)

> muduo 内置的多线程方案,没有使用线程池,而是使用Reactor 池。

有一个 main Reactor 负责accept连接,然后将连接挂在某个sub Reactor 中,然后由sub Reactor 负责检测该连接数据是否到达,以及读写数据(这里都靠sub Reactor完成数据读写而不是线程池中的线程)。

{% asset_img image-20250608162731698.png image-20250608162731698 %}

> 使用多个event loop可以处理不同优先级的连接。使用单个event loop可能导致高优先级的连接不能及时得到处理。

### muduo编程示例

#### 5个简单的TCP示例

echo,discard,chargen,daytime,time

> 关于字符串数组的退化,在传参数时,如果形参为char*,字符串数组自动退化为字符串指针,为了避免这种情况,形参可以参考下面的getCharArray,这样,利用模板自动匹配字符数组,还可以直接获取字符串数组的长度。
>

#include
#include
using namespace std;
template
void getCharArray(const char (&arr)[N]){
std::cout<<”[ztj]:”<<arr<<endl<<N;
}
int main(){
int N = 3;
std::cout << FILE << endl;
const char * src1 = “hello world!”;
//getCharArray(src1);
getCharArray(“hello world!”);
return 0;
};

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

shared_from_this():

该函数是std::enable_shared_from_this<T>类提供的一个成员函数,可以通过继承获得。[深入理解](https://blog.csdn.net/qq_21438461/article/details/142532830)

非阻塞网络编程使用应用层缓冲区的**一个原因**:数据可以随时到达,到达一部分后,如果剩下的部分没有到达,则可以放任数据留在应用层缓冲区中。

#### 文件传输

fopen与open,fopen是对open的封装,可以提供缓冲(通过setbuffer开闭)。当需要读取的数据量很大时,可以考虑增大缓冲区,减少调用open系统调用的次数。(因为采用fopen默认缓冲区大小的话,可能导致多次read)

fread原理:每次 `fread` 都会尝试从这个用户缓冲区中读取数据,只有当缓冲区为空时,才会进行底层的系统调用read来填充缓冲区。

TCP连接是双工的,某一端只能主动关闭自己的写端,读端要等到read返回0才关闭。

#### Boost.Asio的聊天服务器

##### TCP分包

即如何让接收方从字节流中识别出一个个消息。

针对短连接而言,read返回0则代表消息的结尾。

针对长连接而言,分包有如下方法:

* 固定的消息长度,比如muduo的roundtrip使用了固定的16字节消息。
* 使用特殊的字符或者字符串作为消息的边界,比如HTTP的协议的headers以`\r\n`为字段的分隔符。
* 在每条消息的头部加上一个长度字段——常见的作法。
* 利用消息本身的格式来分包,比如XML格式中<root>...</root>的配对。(感觉和方法而很像,都是利用预定的符号来判断结尾 )

文中提到了,新旧两个进程可以使用同一个连接,导致出错的事情,但这个事情不是已经被TCP层的timewait解决了吗。

##### 消息格式

针对多线程服务器,可能有多个线程都会访问CharServer的onconnection函数以及onstringmessage函数,为了保持一致,文中给出了几种方案:(p203)

* server_threaded版本,使用mutex保存ConnectionList列表,但需要读写该列表时,都需要先加互斥锁
* server_threaded_efficient版本,创建一个ConnectionListPtr类型的shared_ptr指向上面的列表,这样,使用时,都使用这个智能指针,这里也需要使用mutex,但这里的mutex只保护智能指针,不需要保护指针指向的列表(虽然也可以保护,但会降低一点并发性)。当需要访问列表时,先申请互斥锁,但需要改变指针指向的列表时,发现指针引用不为1,则直接使用shared_ptr.reset来改变指针的指向(mutex正是保护这个的),否则直接修改列表。(copy-on-write手法)
* server_threaded_highperformance版本,使用ThreadLocalSingleton创建一个每个线程独有的connection列表,通过 distributeMessage 将消息分发到各个线程的 EventLoop 中,再由每个线程分发给自己的connection。

第二种方法已经被广泛应用了。shared_ptr + mutex + cow写时复制,对于多读少写的场景非常高效,而对于高写场景,cow会被频繁复制,代价较高。

如果目标是高并发写,则可以考虑写操作不共享内存,每个线程写各自的vector,感觉和方案三很像。有个缺陷是但需要全局寻找connection时,很低效。

#### muduo Buffer类

Linux上的典型的五种**IO模型**,阻塞IO,非阻塞IO,IO多路复用,信号驱动,异步。

**多核时代,如何选择线程模型呢:one loop per thread总是一个好的模型。使用one loop per thread的话,多线程服务器编程的问题就简化为如何设计一个高效的event loop ,然后每一个线程run一个就行了。event loop是 non-blocking网络编程的核心,non-blocking 几乎总是和IO multiplexing一起使用。**

##### 为什么非阻塞网络编程中的应用层buffer是必要的

非阻塞 `IO`的核心思想是避免阻塞在`read`和`write`或其他`IO`系统调用上.`IO`线程只能阻塞在`IO`复用函数上,如`select/poll/epoll_wait`。这样一来,应用层的缓冲区是必须的,每个`TCP ``socket`都要有 `stateful` 的`input buffer`。

**TCP conn要有输出buffer**:比如当对方滑动窗口已满时,(socket层的buffer也已满时),多余的数据会暂存在应用层buffer中。当socket变得可写时,就应该立刻发送数据。(反正应用层不需要关注这些事情,它可以保证conn send立刻返回)。当调用conn::send后,缓冲区的数据没有发送完毕时,又调用了conn::shutdown时,不会直接关闭TCP连接,而是要等数据发送完毕。

**TCP conn要有输入buffer**:接收数据时,长度为n字节的数据分块到达的可能性有`2**n`种,当socket可读时,应该一次性从系统buffer搬到应用层buffer,否则会不断触发POLLIN事件(水平触发)或者不触发POLLIN事件(边沿触发)。

##### buffer的功能需求

* 对外表现为连续的内存
* 大小可以自动增长
* 内部以`vector<char>`保存数据

就像一个queue一样。buffer的大小需要慎重考虑,buffer太小,遇到大的数据时需要频繁分配内存(产生系统调用),buffer太小,资源浪费太大。muduo的做法是利用一个64KB的栈上extrabuf,当数据太大时,会将多余的数据读到该预期,然后写到buffer中。

**线程安全吗**:非线程安全的,

* 对于input buffer,**onMessage回调也应该发生在TCP conn所属的那个IO线程**,应用程序也应该在onMessage中完成对input buffer的操作。这样就不会导致多个线程访问同一个conn的资源。(每个连接的buffer只能被同一个线程访问)
* 对于output buffer,应用程序只是调用send来发送。

在代码中,使用asserInloopThread来保证在相同线程中访问。

##### Buffer的数据结构

其内部结构是vector,导致其有个机制是capacity,可以减少分配的次数。

内部腾挪:当readIndex移到了靠后的位置时,可以将已有的数据移到前面去。

优化方法:可以考虑使用不连续的内存,提升性能。

##### 性能不是问题

与其优化这点性能,不如优化数据库查询。

如果发现项目中,带宽仍然是瓶颈,那么说明该应用太critical,应该考虑把数据复制放到Linux kernel中去,才能实现 zero copy。或者尝试写新的kernel,或者使用FPGA操作。

#### 一种自动反射消息类型的Protobuf网络传输方案

反射机制:在不知道类的具体结构时,可以通过统一的接口动态的访问和操作消息中的字段。

##### 使用protobuf的先决条件

长度如何处理——在消息前面加上长度信息或者终结符。

类型——protobuf打包的数据没有类型信息,需要由发送方把类型信息传给接收方,接收方创建具体的Message对象,再做反序列化。还有另一种解决方式,传输数据时,加上一个typename,接收方根据这个typename来找对应的Message对象。

其实portobuf已经提供了解决方案。

##### 根据type name 反射自动创建Message对象

protobuf具有很强的反射功能,可以根据type name 创建具体类型的message。

protocal: 协议;

prototype:原型模式,用已有对象作为原型,通过克隆生成新对象(适合不知道具体类型的场景)。

cmake几种查看库文件的机制:(而bazel不会使用系统路径中的库文件,而是自己维护着所有的头文件库文件,保证一致性。也就是说系统路径中的库文件只对cmake等工具编译的文件有影响)

* find_package,优先查找`<PackageName>Config.cmake`脚本或者`Find<PackageName>.cmake`,查找路径是什么呢,分为几种查找方式:
* 显示指定,比如`find_package(Foo Paths /opt/foo /usr/local/foo)`
* 通过CMAKE_PREFIX_PATH查找**
* 通过CMAKE_INSTALL_PREFIX查找(优先级:命令行>文件内>CMakeCache>默认值,注意cmake不会使用环境变量中的CMAKE_INSTALL_PREFIX)
* 通过系统路径查找
* pkg-config,一些老旧小项目会提供`.pc`文件
* 通过环境变量 `PKG_CONFIG_PATH`查找
* 默认路径

cmake的两种构建流程:

之前都习惯使用简单的方式`cmake ..;make;make install`但是这样不现代化,会污染目录。推荐用下面的方式:

`cmake -S . -B build -Dxxx=xxx;cmake --build build;cmake --install build --prefix /path`

`-S`指定源码目录,`-B`指定构建目录,实现源码与构建分离,cmake --build,即执行并发编译,平台无关;cmake --install执行安装。(一些bugs可以参看[bug记录](https://tjzhang-src.github.io/.github.io/2023/04/11/bug记录/))

感觉使用bazel比cmake方便,不用手动管理很多库,而且cmake还无法处理依赖的依赖,,当前也可能是absl没法自动加载。

{% asset_img 664c73683b8d2455380258a7.jpeg img %}

根据typename自动创建Message,使用了单例模式,来访问一个类的唯一一个对象,比如DescriptorPool(获取描述符),MessageFactory对象(获取Message对象)。然后通过获得到的这个Messsage单例对象内部函数创建新对象(原型模式)。(整体看下来,除了往pool中注册对象时需要使用Message的名字,其他时候都不需要使用该名字)

##### 传输格式

struct{
int32 len;
int32 namelen;
char typename[namelen];
char protobufData[len-namelen-8];
int32 checkSum;
}

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

#### 在muduo实现protobuf编解码器和分发器

为什么protobuf不需要再文件中包含消息的长度和类型。因为很多情况下都不需要在数据内部存储数据的类型和大小,比如当把这个消息以UDP方式发给对方时,根据端口类型可以知道类型,根据udp packet可以知道长度。(以长连接TCP发送时,可能不知道长度,因为没办法把多个数据分开,其他很多情况下都不需要保存类型和大小,以节约资源)把数据分开需要使用分发器dispatcher。

##### 编解码器(codec)

> non-trivial——重要的

这里tcpconnection收到的消息会交给protobufcodec进行解析,收到完整的消息在传给server。

{% asset_img image-20250625204734247.png image-20250625204734247 %}

##### 实现protobufcodec

见`examples/protobuf/codec/codec.cc`,主要实现对从tcpconnection收到的数据的一个编解码,然后以messagePtr的形式传递给server使用。但是server还必须要根据消息类型来决定如何处理,如果在protobufcodec层有一个消息分发器就好了。

##### 消息分发器的作用

作为protobufcodec和server的中间部分,分发message到不同的回调函数中去。

{% asset_img image-20250625212936380.png image-20250625212936380 %}

> C++11关键字:static_assert(bool常量表达式,字符串字面量),失败时会显示字面量。
>
> 类型萃取工具:is_base_of(Base, Derived), 判断A是否是B的基类。
>
> 当访问一个含有模板参数的模板类中的某个类型时,需要在前面加上typename,因为编译器没法获取该类型的内部信息。
>
> 模板编译时的两阶段查找:
>
> * 先检查与模板参数无关的成员
> * 在实例化模板时,对非独立的成员进行检查。
>
> 可以参考如下的例子:在一阶段时,还不会实例化模板,即不会产生A\<T\>的具体定义,那么B就不知道会从基类中继承什么,将a变为this->a,就能够让a变为非独立的成员,从而在二阶段进行检查。
>

#include
using namespace std;
template
class A {
protected:
int a;
};
template
class B : public A {
public:
void foo() {
a = 0;//error: ‘a’ was not declared in this scope
}
};
int main() {
B b;
b.foo();
return 0;
}

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



在 Debug 模式你使用dynamic_cast,能立刻发现类型错了,但 Release 模式你可以使用static_cast, 信任程序不会错、直接跑更快。

类型擦除:把不同类型的对象包装成一个统一的基类接口,以便于在不知道具体类型的情况下进行处理,在运行时再复原类型并调用正确逻辑。

- `CallbackT<T>` 擦除了类型 `T`,以统一形式存入 `std::map<Descriptor*, Callback*>`。
- 然后在 `onMessage` 中通过 `down_pointer_cast<T>` 恢复类型。

#### 限制服务器的并发连接数

##### 为什么要限制并发连接数

现在代码中很多都是对TCP层进行操作的,服务端建立好TCP连接后让其放入全连接队列,等待accept的读取,socket内核对象是在三次握手完成的时候就建立了,accept会将这个对象取出来,为其分配一个新的文件描述符给用户态使用(内核应该不用文件描述符,可以直接访问)。客户端通过connect连接TCP连接,建立后返回。

如果进程文件描述符达到上限,那么accept不会从listenfd对应的队列中移除已成功建立的连接,导致一直触发可读事件。

如何限制文件描述符数量:

* 提高数据、死等、退出程序、关闭listen fd。
* 使用edge trigger,漏掉一次后就不会产生该信号了。
* 准备一个空的文件描述符,先关闭这个空闲文件,然后用这个空的文件描述符建立连接后,立刻断开连接。(muduo使用的方式)
* 如果系统的limit是1024那么就建立一个soft limit,超过1000就关闭连接。(感觉这个很优秀呢)

死等和edge trigger都在TCP层面建立了连接,而客户端不知道也不需要知道服务器端是否能够处理该连接(因为服务器程序没有拿到文件描述符)。

##### 在muduo中限制并发连接数

在server中加上一个int或者Atomicint成员,超过一定计数则终止连接。

i++在编译器看来会执行`mov xxx [i], add xxx, 1, move [i],xxx`三条指令,这中间随时可能被中断。

L1 Cache通常是每个核心私有的,通过虚拟地址进行访问。其他两个Cache通过地址转换后,通过物理地址进行访问。

#### 定时器

与时间相关的任务:

* 获取当前时间,计算时间间隔。
* 时区转换与时期计算。
* 定时操作。

##### 时间函数

数据对各种时间函数进行了取舍,

* 计时使用gettimeofday(当使用glibc库时,会通过VDSO机制避免陷入内核态)其他方法要么开销大要么精度低。(微秒定时)
* 定时使用timerfd_*系列函数来处理定时任务。
* 避免使用sleep/alarm/usleep函数,他们可能使用了SIGALRM信号,而很多信号是针对进程的,一个进程中的所有线程都可以接收某个信号。要避免在多线程程序使用信号。
* nanosleep和clock_nanosleep是线程安全的,但会让当前线程挂起,但在非阻塞网络编程中,不应该出现这种会让线程等一段时间的行为,所有函数都应该立刻返回,可以考虑使用使用回调函数来实现。
* 传统的reactor利用epoll实现的定时,精度只有毫秒。

eventloop有三个回调函数:runAt,runAfter,runEvery来执行对应的回调函数。

##### Boost.Asio 定时器的实现

通过在python中通过subprocess调用bash脚本,在bash脚本中将输出重定向到一个文件中,可能发现文件中的内容出现的很慢,可以考虑在命令前加上`stdbuf -oL`来关闭缓冲区,

ntp协议:校准本机的时间,使其接近时间服务器的时间。两个重要的计算公式:

* delay=(T4−T3)+(T2−1),计算的是包在网络中传输花费的时间。
* offset=((T2+T3-T1−T4))/2,这里使用**时间服务器的时间**减去客户端的时间,如果包在网络中收发的时间是相等的,可以通过这个式子计算出两者之间的时间差。(比如T1=100,T2=201, T3=202,T4= 103,假设包在发送和接收的时间都是一致的,都是1,这里可以计算出客户端与时间服务器的时间差为100)

nagle是TCP网络中为了提高网络吞吐量并减小小数据包从而设计的算法。

### Time 踢掉空闲连接

如何踢掉空闲连接(比如说8秒无消息的连接立刻断开):

* 可以每隔一秒,对**所有的连接**进行一次检查,断开8秒都没有收发消息的。(缺点是遍历的成本太高了)
* 为每个连接设置一个定时器,超时为8s,超时则断开。(缺点是对定时器的需求大)

可以使用timing wheel来解决:一共有8个集合,分别装着将要在1,2,3直到8秒过期的连接,这样只需要8个定时器,也可以避免遍历大量的Conn连接。

这里面使用了大量的weakptr和sharedptr来处理问题。

* 首先将一个conn连接用一个Entry来表示,Entry使用一个指向Conn的虚指针来初始化(避免使用shared_ptr,这会导致Conn引用计数无法归零无法析构),Entry析构时,会导致Conn连接关闭。
* 在Server类中,使用若干个bucket来保存不同时间的Entry的shared_ptr。(为什么需要使用shared_ptr, 因为这里确实**需要强引用来计数**,因为可能出现一个conn的entry被插入到多次)在loop注册一个每秒的定时器,来删除最旧的bucket,进而删除其中的shared_ptr,导致其指向对象的计数减一,归零则导致conn被关闭。
* 其实conn的内部变量也保存了指向对应的Entry的weak指针。(注意:使用share_ptr相互引用是绝对不允许的,因为这影响了两者的生命周期,当weakptr可以,他对两者的**生命周期没有任何影响**)

splice: 把某些元素从一个 `list` 中“剪切”下来,**不拷贝,不构造,不析构**,然后“粘贴”到另一个位置。

##### 消息广播服务

snapshot & delta, 通常出现在谈论**数据同步**中,snapshot是某一时刻完整的数据副本,delta只保存自上一状态以来的变化部分。(这么看来使用snapshot要稍微可靠一点)

如何实现高效广播:比如服务器要发消息给一千个订阅者,单线程只能一个一个的发,可以使用多线程,当要考虑如何避免全局锁的使用。还可以参考`server_threaded_highperformance.cc`的做法,创建thread_local变量,将某个订阅者交给某个线程单独管理。

为什么有些函数需要使用const &来进行传递:

* 普通函数不包含资源,无需使用这种方式。
* 函数对象以及有捕获的lambda表达式包含资源,使用const &可以节省复制开销。

实现了hub,sub,pub后,如果服务器有多个客户端连接,可以考虑使用多个线程提高性能,为了避免锁把多线程变成单线程,可以考虑使用线程局部变量。

shared_ptr实现cow:(有两个对象需要保护,第一个时shared_ptr对象,第二个是shared_ptr指向的对象,shared_ptr可能被多个线程读写,比如一个线程reset shared_ptr后,另一个线程访问shared_ptr会出现问题。shared_ptr依靠锁来保护自己——只针对需要读写shared_ptr的多个线程——访问shared_ptr变量时需要先加锁。shared_ptr指向的对象可以依靠引用计数)

#include
#include
#include
// 共享资源
std::shared_ptr<std::vector> g_vptr;
// 该互斥锁只用来保证访问shared_ptr时的线程安全
// 读写std::vector的线程安全通过shared_ptr的引用计数保证
std::mutex g_mutex;
void read(){
std::shared_ptr<std::vector> vptr;
{
std::lock_guardstd::mutex g(g_mutex);
vptr = g_vptr;
}
for(auto i : *vptr){
// read
}
}
void write(int data)
{
std::lock_guardstd::mutex g(g_mutex);
if(!g_vptr.unique())
{
g_vptr.reset(new std::vector(*g_vptr));
}
g_vptr->push_back(data);
}

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

##### 串并转换(多路复用)服务器

与系统级别的复用方式比如poll和epoll不同,这个Multiplexer 是针对应用层的一个复用方式。

##### 与其他库集成

channel:

### 网络库设计以及实现

利用`#pragma once`来让头文件只被包含一次。

#### Reactor的事件分发机制

将IO multiplexing拿到的IO事件分发给各个文件描述符的处理函数。

##### channel class

每个channel对象只负责一个文件描述符的IO事件分发,一个channel对象只属于一个IO线程。

关于channel和tcpconn的生命周期,tcpconn拥有channel的unique指针。问题是,channel收到消息后,会调用tcpconn的回调函数,可能导致tcpconn关闭,可能导致channel也被关闭,所以channel中保存了tcpconn的shared_ptr防止tcpconn先die。

##### Poller class

Eventloop中包含成员poller,Eventloop拥有Channel(有他的unique指针),poller不拥有channel(只有channel的原始指针)。

#### TimerQueue 定时器

可以利用select和poll的等待时间来进行定时。也可以利用linux中的timerfd进行定时。这种方式可以更好的融入到eventloop中。

如何组织当前未到期的Timer:

* 按照到期时间排好序的线性表,复杂度O(N)
* 用堆来组织(make_heap),复杂度O(logN),缺点是没法高效的删除其中的某个元素
* 二叉搜索树(set, map), 操作的复杂度时O(logN), 相比于堆来说内存局部性更差。

第一次看,怎么muduo的定时器这么畸形。

* Timer类中包含了关于定时的所有信息,
* TimerId包含了指向Timer的指针以及一个序列号,
* TimerQueue中的Timers包含了所有按照过期时间排序的timers,格式为<Timestamp, Timer*>, 当定时器的文件描述符收到数据时,可以很快的找到前n个过期的定时器。
* TimerQueue中的activeTimers_包含了<Timer*, int64_t>,第二个模板参数是序列号,

#### runinloop函数

在IO线程中,平时都等待在poll调用中,为了让IO线程能够立刻指向用户回调,需要唤醒该线程。比较传统的方法是使用pipe,让IO线程持续监视此管道的readable事件,现在可以使用Linux的eventfd文件描述符(不必管理缓冲区,从而实现高效的唤醒)。

#### 实现TCP网络库

通过`Acceptor`的构造函数以及`Acceptor::listen`函数来执行创建TCP服务器的传统步骤,即调用socket、bind、listen,这其中如果遇到任何错误都一样终止程序。(很多情况下都是因为端口被占用)

SO_REUSEADDR可以解决端口复用问题。

SO_REUSEPORT让多个**socket服务**绑定到同一个端口,比如ssh服务与xrdp服务都绑定到80端口。

代码中使用`idleFd_(::open("/dev/null", O_RDONLY | O_CLOEXEC))`来创建一个无用的文件描述符,这样,当文件描述符用完时,可以关闭该无用的描述符,然后关闭新连接。

TCP 三次握手由**内核自动完成**,并在 `accept()` 之前就完成了。`accept()` 做的事情是:**从内核中“取出”已完成握手的连接,返回一个新的文件描述符**,交给用户态处理。(文件描述符只是给进程使用的,内核不需要文件描述符)

在accept之后,可以立刻通过非阻塞的poll进行一次检测,如果连接有问题则立刻断开连接。

前向声明不需要`#include`对应的头文件,前向声明只是告诉编译器:这个类型存在,但现在我不需要知道它的全部定义

```c++
/**
* @details要保证在执行这个函数期间,这个channel对象不能被销毁,一旦出了这个函数,
* 就无所谓了
*/
void Channel::handleEvent(Timestamp receiveTime){
std::shared_ptr<void> guard;
if (tied_){
//这里主要是因为可能在上层的函数中关闭conn,而关闭channel后,会导致
//channel也被关闭,这里利用guard来保证conn在此函数作用域内不会被释放。
guard = tie_.lock();
if (guard){
handleEventWithGuard(receiveTime);
}
}
else{
handleEventWithGuard(receiveTime);
}
}

删除vector中的元素时,如果其中没有顺序关系,可以将要删除的元素和结尾的元素swap一下,然后pop_back。

不知道会收到多少数据,但希望尽可能读取完数据,可以使用buffer+栈内存两块缓冲区通过readv一起读取(栈内存分配快)。

高效的唤醒一个线程的思路:

  • 利用条件变量,
  • 使用eventfd,(epollfd友好)
  • futex,必要时才进内核

完善Tcpconnection

sigpipe

这个信号的默认操作会kill掉当前进程,应该忽视他。

tcp no delay和tcp keepalive

前者是禁用Nagle算法,避免发包出现延迟,后者会定期探查TCP连接是否存在。

写buffer数据过多时,可以设计一个高水位回调,用来进行一些优化。

多线程TcpServer

多线程的情况下,TcpServer和TcpConnection的代码都只处理单线程的情况,(虽然一个线程很可能在其中一个对象中访问到另一个对象,造成冲突),这里利用EventLoop::runinloop来避免这个问题。并且线程切换,ioLoop和loop_之间的切换都只会发生在连接建立关闭的时候。

分布式系统

单机vs分布式:

  • 单机的总线默认是非常可靠的,根据能否使用就能知道某个进程、部分发生了故障。
  • 分布式的网络传输默认是不可靠的,我们无法区分是网络问题还是对方机器故障。
分布式系统是个险恶的问题
负载均衡

如何判断服务器的负载——根据反应时间,这样可以很大程度上的简化设计。哪怕是多个Web Server,也能做到均衡的负载均衡。

时间和事件违反直觉

每个观察者有自己的时钟,而且消息传递的延迟是不固定的。

可靠性

Tmtbf: 平均无故障运行时间,
$$
Reliability = exp(-t/tMTBF)
$$
平均无故障运行时间不是用来估计单个部件什么时候坏的,而是用来估计需要准备多少备用部件。(预测的是一个宏观概率,比如厂家说1000小时有50%的可能出现故障,那么一个部件使用1000小时和1000个部件使用1个小时,都有50%的概率出现故障)

可靠性:数据不丢失的概率。

可用性:可随时使用数据的概率。

可用性 = tMTBF/(tMTBF + tMTTR)(tMTTR代表平均修复时间)

这上面讲了很多硬件故障,他和软件故障同时存在,软件的可靠性只需要略高于硬件及操作系统即可。(比如硬件错误一年10次,那么一年出现2次软件错误也无伤大雅)

高可用的关键不是不停机,而是出现故障停机后能立刻恢复。

能随时重启进程作为程序设计目标

由于可能发生各种故障,导致进程被关闭,所以进程应该具备快速重启的能力。

要能够实现可重启,程序应该使用操作系统能自动回收的IPC,不能使用跨进程的mutex,semaphore,pipe等。

协议设计时应该让客户端在TCP断连后自动重连,(也可能需要检测服务端心跳,并自动failover到备用地址,ps.这么复杂么)。

如何重启:

  • 先主动停止一个服务进程心跳
    • 对于短连接,关闭listen port
    • 对于长连接,客户会自动failover到其他服务端
  • 等一段时间,直到该服务进程没有请求
  • kill并重启进程
  • 检测新进程的服务是否正常
  • 依次重启服务端剩余进程

这里既要求客户端正确处理心跳以及TCP重连,还要客户端同时兼容新旧版本的协议。

分布式系统的心跳协议

Fin报文不能作为对端能否工作的充要条件,需要利用心跳探查对端能否正常工作,TCP的keepalive也有不足,例如对端进程阻塞,但是操作系统仍然能够处理TCP的keepalive。

心跳由服务提供者发送给服务使用者,收到心跳代表服务正常,没收到代表服务错误。

使用心跳 vs. 被动故障发现

使用心跳的情况:系统要求高可用、低延迟切换。

可以不用心跳:系统对延迟容忍度高。

NTP:

T1,T2,T3,T4:假设消息来回的时间相等,来回总延迟即为Delay = [(T4 - T1) - (T3 – T2)]/2, 本地计算机与服务器的时间偏差即为T3 + Delay - T4也即为[(T2 - T1) + (T3 – T4)]/2,

心跳可以让对方知道自己当前的状态,包括网络上的拥挤程度。所以:

  • 要在工作线程发送心跳,不要单独使用心跳线程。(如果使用了,无法反应当前工作线程的拥挤程度)
  • 与业务消息使用同一个连接,不要单独使用心跳连接。(如果使用了,无法反应当前连接的拥挤程度,比如说:心跳的连接一直活跃不会被防火墙kill,但是业务连接不活跃,被防火墙kill)

要避免在工作线程中使用TCP, 而在心跳中使用UDP。

分布式系统中的进程标识

如何标识一个进程?如果该进程是无状态的,可以直接用ip:port来标识,如果服务是有状态的,只是用ipport是不行的。

能否使用pid,即ip:pid,也是不行的,这两者无法标识一个唯一的进程。(因为同一个pid编号可能被重复使用,绝不能出现两个标识相同但进程不同的情况)

正确做法

ip:port:start_time:pid来标识,这基本上能保证每个进程的唯一性:

  • 如果进程在极短时间内重启,即使start_time相同,但是因为pid是顺序使用的,必然不可能用完一圈再回到当前pid
  • 如果重启时间很长,虽然pid可能重复,但是start_time差别很大

一般分布式系统中每个会与其他机器打交道的进程都需要维护一个管理接口。

其实TCP为了避免相同地址旧连接的干扰,使用序列号来避免这个问题,很类似这里的start_time以及pid的结合体。

构建易于维护的分布式程序

维护针对于维护人员说的,管理起来很方便,而不是针对代码开发人员说的

为什么为长期进程提供一个管理接口很重要:

  • 必要性
  • 便利性,利用HTTP使用HttpServer程序。

为系统演化(升级)做准备

(跨语言的)可拓展消息格式
  • 服务端升级时,不应该靠消息中的版本号来分发消息——这很容易产生一大堆垃圾消息
  • 设计消息头时,避免通过TCP连接发送C struct或者bit fields格式的内容,这有很多缺点包括:非C/C++语言很难处理这种情况,很难升级。解决办法:使用中间语言来描述消息格式,
    • 如果使用文本格式,使用JSON 或 XML。使用文本格式时,需要考虑针对不可用字符的转义字符。
    • 如果使用二进制格式,使用GOOGLE protocol Buffers。要注意的两点:
      • 不能改变已经存在字段的标签号(应该指的是顺序吧)
      • 不能添加或删除必填字段

分布式程序的自动化回归测试

自动化测试与被测程序是一种互补关系,对于维护系统的稳定性很重要。

单元测试的能与不能

一些机制:

  • RTTI:通过运行时类型识别,程序能够使用基类的指针或引用来检查着这些指针或引用所指的对象的实际派生类型C++中的RTTI机制解析

  • 反射机制:反射让程序在运行时获取和操作类的信息(而不是在编译时写死)。比如说访问Class.newInstance()来创建对象就利用了反射机制。反射教程

    1
    2
    Class<?> clazz = Class.forName("com.example.MyService");
    Object obj = clazz.getDeclaredConstructor().newInstance();
  • 依赖注入:一种设计模式,去除Java类之间的依赖关系,实现松耦合。(感觉使用依赖注入后,类中的局部对象就是通过参数传入的,而不是在类的构造函数中写死的)依赖关系为A类依赖B的父类B0类,在A类与B0类的依赖关系下,A类可使用B0类的任意子类。这里用到了多态机制,当需要动态创建实例时,需要利用到反射机制。理解依赖注入

单元测试的一些缺点:

  • 单元测试本质是白盒测试,测试代码需要调用被测代码,必须明确写出被测代码的包、方法、参数、返回列表才能调用成功。一旦后期改变了被测代码的参数,带来了很大的工作量变化。
  • 为了方便测试而施行依赖注入,破坏代码的整体性。

反正作者觉得class或者function级别的单元测试对分布式系统的帮助不大。作者认为要测试这些进程之间的交互。(其实当初对blender渲染任务做负载均衡时,也使用了一个模拟的客户端来检测服务端是否正常)

mock:模拟对象,用于在测试中模拟真实对象

回归测试:用于检测修改代码后,有没有引入新错误(防止质量倒退)

这种测试也具有一定的局限性:

如果测试过程中还涉及到一些其他的IO, 比如通过TCP连接数据库,这是比较难模仿的,要么就只能部署测试数据库。

分布式系统部署、监控和进程管理的境界

境界一:高校水平

手工管理各个服务器程序,ip,port啥的。

境界二:零散的自动化脚本以及第三方组件

境界三:高效自动化的控制

举例:Master/Slave/Client结构:Master节点作备份,Slave进程包含多个执行任务的子进程,可以通过sigchld及时指导进程的启停。

naming service: 命名服务,即提供名字(name)到资源地址(如 IP 或 host:port)之间的映射服务

境界四:机群管理以及naming service的结合

zurg的一个缺陷是无法自动化的failover。(出现错误时应该立刻切换系统)

实现快速的failover:利用naming service 代替DNS即可。(DNS使用超时轮询,慢,而且不支持端口)

可以实现自己的名字服务:将service_name解析成list of ip:port, 名字服务可以将新的地址信息推送给客户端。

naming service是系统正常运行的关键,多台服务器保证可用性,Paxos算法保证一致性。

一致性算法:

paxosPaxos、Raft分布式一致性算法

C++编译链接模型

C++20才有模块机制,可以直接使用其他文件提供的接口。之前都是只能通过引入头文件来使用的,缺点很多:

  • 一方面效率很低
  • 一方面可能出现冲突(比如写QT时,slot被覆盖了,需要更换两个头文件的顺序才行),
  • 一方面很带可能来隐患,头文件时编译时使用的,库文件时运行时使用的,如果两者的版本不一致会导致很多问题。

c的出现受制于当时的硬件条件,单遍编译、先定义再使用、局部变量必须在开头定义、对于外部变量只需要指导类型和名字,不需要知道地址,这些都是为了在低资源硬件环境下编译c程序做的规定,由于cpp想要兼容c,导致他也接受了这些糟粕。

关于为什么c会分为头文件和源文件,其实即使是现代的C编译器,头文件也不是必须的:

1
2
3
4
5
6
7
8
9
10
//cat alpha.c;cat beta.c
int main() {
print_hello();
}
void print_hello() {
puts("hello");
}
//gcc alpha.c beta.c -o app;
//./app
hello

对于C语言而言,遇到未知符号,则将其作为外部符号等待链接就行了。

  • 这种写法基本上能work,是因为当时的数据类型比较简单,就只有int。
  • 而且这么做可以很方便的将从不同语言编译到的目标文件链接到一起。(gcc在编译期间就已经丢弃了类的元数据)

随着结构体的引入,问题变得更加复杂,函数调用需要提前声明,这就是头文件出现的缘由。有个问题是为什么不能直接去查找对应的源文件呢,因为C假设对方可能不是C编写的代码(比如可能是Fortran、汇编),所以每个源文件都只包含最基本的机器代码(没必要包含类的元数据)。所以为了访问C语言编写的其他函数或者其他语言(比如汇编)编写的其他函数,都需要为其创建一个匹配的头文件,然后供需要的源文件使用。

虽然C编译可以直接产生目标文件,从而很方便的与其他语言链接,不过常用的语言貌似就只有汇编。如果能和其他功能更强大的语言链接,可能C还会更常用。可以“直接产生目标文件”的这个特点也带来了很大的缺点,比如目标文件不包含元数据,导致C/C++很难实现一些java中的特性,反射啥的。

C++的编译模型

单遍编译

编译器只能根据当前看到的代码做决策。这特别影响了名字查找(name lookup)和函数重载决议。

名字查找:

C++中的名字包括类型名、函数名、变量名、typdef名、template名,比如Foo<T> a;如果不知道这三个名字是什么,那么可以有很多种合理的解释

  • Foo是个template<typename X> class Foo;,T是类型参数
  • Foo是个template<int X> class Foo;, T是一个非类型参数。
  • Foo,T,a都是int变量,这里只是对三者比较大小。
  • 而且<还有可能被重载

还有一个经典的例子,AA BB()是声明函数,而非定义变量。一个更有迷惑性的是MyClass obj(MyClass()),他是声明一个函数obj,他接收一个函数作为参数,这个函数返回MyClass类型的对象,不接受参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
using namespace std;
struct MyClass {
MyClass() { std::cout << "MyClass constructor\n"; }
void say() const { std::cout << "Hello\n"; }
};
// 这行到底是函数声明还是对象定义?
MyClass obj(MyClass());
int main() {
obj.say(); // ❌ 如果是函数声明,这里编译会报错
return 0;
}
####################[测试]####################
MyClass.cpp:12:9: error: request for member ‘say’ in ‘obj’, which is of non-class typeMyClass(MyClass (*)())’
12 | obj.say(); // ❌ 如果是函数声明,这里编译会报错
| ^~~

由此可见,cpp理解名字有着很大的误差。主要原因是因为cpp只能根据解析源码来了解名字的含义,需要将文件从头到问解析一遍。而其它语言可能通过直接读取目标代码的元数据来获取所需信息。(比如java会将元数据编译进.class文件,运行时的反射机制也是通过这种元数据实现的;cpp不会保留每个class的元数据,也没法实现反射)

为此作者提出使用 -Wshadow选项来对同名变量的遮挡情况发出警告。

1
2
3
4
5
-Wall:启用所有常见警告。
-Wextra:启用更多额外的警告。
-Werror:将所有警告视为错误。
-Wconversion:警告隐式类型转换,这可能会改变值。
-Wshadow:警告变量遮蔽(即在内层作用域声明一个与外层作用域同名的变量)。
函数重载决议

当编译器读到一个函数调用语句时,他只能从当前已经看到的同名函数中选择最佳函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
using namespace std;
void foo(int){
std::cout << "foo(int)" << endl;
}
void foo(char){
cout << "foo(char)" << endl;
}

void bar(){
foo('a');
}
/*
void foo(char){
cout << "foo(char)" << endl;
}
*/
int main(){
bar();
return 0;
}
####这里交换顺序后,结果是不同的

其实由于C++’新增了不少语言特性,C++编译器并不能真正像C那样进行过眼即忘的单遍编译。但是C++必须兼容C的语意,因此编译器不得不装得好像是单遍编译(准确地说是单遍parse)一样,哪怕它内部是multiple pass的。

前向声明

利用前向声明来较少编译期依赖,

函数声明与定义:通常能够查出参数列表不同,但不一定能查出返回类型不同。原型声明的变量名是无效的,以定义时为准,

有时候,只使用前向声明就已经足够了:

  • 定义或声明Foo*Foo&,用于函数参数,返回类型等等。
  • 声明一个以Foo为参数会返回类型的函数。(只在声明语句中使用就没事)

不能重载&& || , operator&这四个运算符。

链接

两个重要的问题:

  • 函数重载,名字改编(生成独一无二的符号名)。利用extern “C”来和C程序库集成。
  • vague linkage(模糊链接),C要求一个符号在一个程序只能有一个定义;而对于有模糊链接属性的C++对象,每个定义是弱定义,最终会有链接器去选择一个定义。
函数重载

为了实现重载,需要先利用名字改变来为重载函数生成独一无二的名字,生成的名字像下面的样子:_Z3foob,

  • 这也是为什么不建议变量用下划线开头的原因。
  • 这也是为什么函数名、参数数量、参数类型、参数顺序会影响重载的原因(会导致修饰后的名称不一致);而参数名和返回值类型不影响。

这也导致一个bug,可能使用的函数原型的返回值是错的,但是可以链接没有报错。

inline函数

如何在头文件定义函数,可以使用inline函数来避免重复定义。

模板

当模板在不同的文件具现化模板后,相同的模板的函数在不同的目标文件中定义,为了避免重复定义问题,模板的成员函数具有若定义的属性。这样可以避免报错。(类似的,inline函数也具有弱定义的属性,当inline失败时,为了避免多个文件同时定义该函数,利用弱定义)

  • 通常在编译期就会实例化模板,需要给出对应类型的定义,这要求我们必须将模板的定义放到头文件中。
  • 但其实,如果编译期不给出模板的定义,只给出模板的声明,在编译期不会报错,然后在连接阶段会报找不到定义的错误。为了解决这个问题,可以在任何一个能访问到模板定义的地方实现实例化需要用到的类型。(可以利用这种方式来限制用户只能使用某几个特殊的模板类型

当写一个库foo时,如果他依赖一个模板bar,则可以将模板bar的定义放到源文件中,bar的声明放到头文件,然后再源文件实例化几个需要的类型。这样可以避免用户的代码访问到模板,提高编译速度。

C++可以使用extern template特性,可以手动实例化,从而避免每个使用模板的文件都进行实例化,提高编译速度。

虚函数

每个定义或者继承了虚函数的类都有一个vtable。而该类的每个对象都有一个指向该表vtable的指针,即vptr

头文件的使用规则

头文件的害处
  • 传递性,头文件可以导入其他头文件,导致代码膨胀;任何一个头文件改动都会重新编译所有依赖于它的源文件。
  • 顺序性,一个头文件可以包含多个头文件,这些头文件之间可能有顺序区分。
  • 差异性
    • 内容差异,两个源文件编译时,如果编译选项不一致,会导致二进制代码不兼容,所有源文件都应该使用相同的编译选项。(如果需要使用其他人的库,除了拿到头文件和库文件,还需要知道这个库的编译选项。)
    • 时间差异,头文件与库文件的内容不一致。

反观现代的编程语言,模块化做得更好:

  • 对于解释性语言,import的时候直接将对应模块的源文件解析一遍。
  • 对于编译性语言,编译出来的目标文件包含了足够的元数据,import的时候可以直接读目标文件的内容。

这两种方式都可以避免声明定义不一致的问题。

头文件的使用规则

常见的观点:

  • 将文件的编译依赖降至最小
  • 将定义式之间的依赖关系降低至最小,避免循环依赖
  • 让class名字、头文件名字、源文件名字直接相关
  • #include once用的名字应该包含文件的路径的全名

tips: 如何知道某一个类/函数是通过哪个头文件被引入当前源文件的?比如要知道程序是通过哪个文件引入string这个类的,可以在当前目录创建一个包含#error error的名字为string的文件,然后在编译时使用-I .选项将当前目录纳入头文件搜索路径。(有个问题是如何通过一个类名判断它所在的头文件是哪个,可以通过一些标准网站来查找,比如string类对应的头文件可以在这里面找到std::basic_string - cppreference.com

1
2
3
4
5
6
7
8
9
10
11
rtlab-3090:~/cpp/laji/string$ g++ test.cpp -I . -o ooo
In file included from /usr/include/c++/11/bits/locale_classes.h:40,
from /usr/include/c++/11/bits/ios_base.h:41,
from /usr/include/c++/11/ios:42,
from /usr/include/c++/11/ostream:38,
from /usr/include/c++/11/iostream:39,
from test.cpp:1:
./string:1:2: error: #error error
1 | #error error
| ^~~~~

这种找头文件的方式与通过IDEctrl+click跳转还是有一点区别的,假如有如下几个文件:

  • locale_classes.h 该文件中包含了<string>,意味着所有引用locale_classes文件的文件都会包含string,
  • basic_string.h, 在string文件中,引用了basic_string文件,然后在basic_string文件中,有着string的定义,

当通过IDE的ctrl+click跳转时,会直接进入basic_string文件,找到string定义的地方。

通过前往所述的方法,利用显示从当前文件到locale_classes之间的整个调用路径,它能找到的最深的文件路径是直接引用string的那一个文件。

工程项目中库文件的组织原则

改动程序或者它依赖的库都应该重新测试。

像java,python,需要检查库、解释器、jvm等的版本。

库API(头文件):人类可读的接口,比如函数签名,参数

编译器ABI:编译器如和将代码翻译为二进制,比如函数调用规则,对齐,名字修饰等。

库ABI: 包含了库API以及编译器ABI。

操作系统ABI:规定了系统调用和底层服务的二进制接口,比如如何触发系统调用,int 80等。

编译器版本与标准库版本直接相关。

C++库的发布方式:

动态库 静态库 源码库
发布方式 头文件+.so文件 头文件+.a文件 头文件+.cc文件
查询依赖关系 通过ldd可以查询一个可执行文件的依赖 编译期信息(还有cmake啥的吧) 编译期信息(还有cmake啥的吧)

要保证应用程序之间的独立性,要让多个动态库的多个版本能够共存。

动态库的危害

虽然通过更新动态库,可以随时增加新特性,但是更新动态库的版本后,程序的行为变得不可预期。(能不能正常运行都是一个问题)

静态库的危害

优点:

  • 依赖关系在编译期确定
  • 发布速度更快,比较没有PIT表(Procedure Import Table)
  • 只需要发布单个可执行文件

向后兼容:

  • 对代码而言,指的是新版本的代码/软件,能够兼容旧版本的数据格式、接口或功能。
  • 对编译器而言,指的是新版编译器仍能正确编译、运行旧版本语法或代码风格写的程序。

依赖冲突(钻石依赖):当前应用程序间接依赖于某个库的多个版本,可能导致符号冲突,不可预判的行为。

静态链接在针对这种依赖冲突的问题,虽然可能将报错从运行期提前到编译期,但解决问题依然很麻烦,以下几种可能遇到的问题:

  • 一个依赖于库A的库突然依赖更高版本的库A时,导致其他依赖于库A的库都需要升级高版本。
  • 重复链接,应用程序依赖于某个库的多个版本

更新系统时,新系统的系统ABI可能与旧系统不兼容,理论上需要重新编译库文件。更新编译器时,编译器ABI(比如为C++11修改了ABI)可能发生变化,理论上需要重新编译库文件。

如何解决源码库吗?

源码编译

可以确保所有源文件在相同的条件下编译的,不用为库的版本操心。

由于C++的头文件与源文件分离,并且编译产生的目标文件.o没有包含足够的元数据,如果要在项目中使用一个库,还必须提供对应的头文件。因为同时需要这两个东西,就会导致这两者不匹配的情况。(c++编译的目标文件就只是二进制代码,并不包含任何如何使用该类的信息,比如函数声明,类结构)

from gpt:

C++ 有必要用库吗?

必须的。因为 C++ 本身没有电池齐全(batteries included):

  • C++ 标准库太“基础”了(比如没网络库、图形库、并发框架、解析库等)
  • 高质量第三方库是构建现代 C++ 项目的核心
    例如:
    • 并发网络:Boost.Asio, libuv, gRPC
    • 序列化:protobuf, msgpack, flatbuffers
    • UI:Qt, FTXUI
    • 日志:spdlog, glog
    • 单元测试:GoogleTest, Catch2

C++面向对象与虚函数

虽然 C++ 支持面向对象编程(如类、继承、多态等),但它的语法和机制在设计上并不如 Java、C# 等“原生支持”面向对象的语言来得自然和系统化。它是一种通过对 C 语言的扩展实现的 OO。

C++ class 是值语义,而非对象语义。这带来的影响很多:

  • 直接赋值时,会发生拷贝构造,而非引用复制。
  • 如果想使用深拷贝,需要显示编码

朴实的C++设计

C++复杂,但不代表要使用复杂的方式来使用它。(作者不建议大规模使用面对对象、继承、多态)

利用接口类/实现类的一个好处就是可以实现依赖注入。(将对象从类的外部传入)

程序库的二进制兼容性

比如一个图形库,支持1920*1200像素,很多项目用到了该库。后来为了支持2560*1600像素,可以有两种解决方式:

  • 发布一个新的图形库,让使用了该图像库的其他项目重新编译。这会导致其他项目重新发布一个版本。
  • 更新现有的库文件,但是新库和旧项目可能出现不兼容的问题。

本章的二进制兼容性主要是指库文件单独升级,现有可执行文件是否受影响,主要是动态链接库的 ABI(怎么感觉应该是API呢), 至于编译器以及操作系统的ABI 见上一章。

二进制兼容性

使用动态库的方式提供函数库,那么头文件和库文件不应该被轻易修改。

希望在升级库文件(或者Linux Kernel)时,不必重新编译使用了这个库的可执行文件。(在可执行文件仍然使用旧的头文件时,仍然可使用新的库文件。这意味着我们的可执行文件不用重新编译,不用发布新版本,想一下,如果依赖的每个库更新时,我们都需要重新编译以及发布,那么带来的工作量有多大)

源代码兼容性:老代码使用新头文件,能编译通过。(感觉二进制兼容比源代码兼容要求要高)

向后兼容性:(兼容过去的版本,不同的软件版本,给使用者留下了一个使用接口,最新的软件仍然提供老版本的使用接口,则是兼容)在计算机中指在一个程序硬件更新到较新版本后,用旧版本程序创建的文档或系统仍能被正常操作或使用,在旧版本库的基础上开发的程序仍能正常编译运行。

当多个应用或库依赖不同版本的同一个 DLL,但系统无法正确加载对应版本时,就会出现“DLL Hell”。

什么情况会破坏库的 ABI

这取决于C++的实现方式,比如Visual C++, G++。

C++编译器 ABI 的主要内容包括以下几个方面:

  • 函数参数传递的方式
  • 虚函数的调用方式,通常是vptr/vtbl
  • struct 和 class 的内存布局
  • name mangling
  • RTTI 和 异常处理

C/C++是通过头文件来暴露出动态库的使用方法(函数调用以及对象布局)。通过判断这个头文件的使用方法是否与新版本库的实际使用方法相匹配,就知道这个改动是否是二进制兼容。

比如:

  • 动态库中有 non-virtual函数void foo(int),新版本的库变为了void foo(double),这会导致两个函数的名字修饰不一致,进而找不到符号。
  • 对于上面的情况,将non-virtual函数变为virtual函数。虚函数不是通过签名(符号)来找对应的实现的,而是通过索引。在编译时,就会将 foo 函数转变为内部 vtable 中某个索引(即函数指针)的调用(索引顺序是声明顺序)。当库被修改后,对应索引的函数指针变化了,如果还是按照旧版本的头文件使用索引,虽然编译不会报错,但在运行时会遇到未定义的行为。(一种可能的做法是在foo函数后面新增新的函数,这样生成的vtable只会在后面新增索引,也许能保证向后兼容还得考虑当前类是否被继承。

源代码兼容但二进制不兼容的例子:

  • 给函数增加默认参数(默认参数是通过在编译时在调用函数时手动加上该参数来实现的,默认参数只存在于函数声明处,无需出现在定义处,定义不知道也不关心有没有默认参数
  • 增加虚函数,会导致vtbl的排列变化,要考虑当前类是否被继承。
  • 增加默认模板参数类型Foo<T>变为 Foo<T, Alloc=alloc<T>>
  • 改变 enum 的值

考虑如下的情况:给class Bar 新增数据成员,导致 sizeof(Bar) 变大以及内部结构变化,这通常不安全,也有例外:

  • 如果客户代码中使用了new Bar,这是不安全的(因为会调用malloc,会根据头文件中的数据类型来确定类的大小,这和源文件中类的大小不一致;然后会调用源文件中的构造函数,这很可能会导致用来未分配的内存)。可以通过factory返回Bar*或者shared_ptr<Bar>
  • 避免直接访问类中的变量,而是通过成员函数访问。
  • 避免调用inline修饰的成员函数。而是使用outline function
哪些做法可能是二进制兼容的
  • 增加新的 class
  • 增加 non-virtual成员函数或static成员函数
  • 修改数据成员的名称,二进制代码是通过偏移量访问数据成员的。
  • 个人测试:修改只虚函数名字,并保证参数返回值,虚函数声明顺序不变,貌似也可以实现二进制兼容。见cpp/muduo/chapter11/virtual
为了避免二进制不兼容

有如下的做法:

  • 使用静态链接
  • 仔细管理动态库的版本,详见共享库命名方式
  • pimpl技法,多一层间接性

避免使用虚函数作为库的接口

如果打算写一个库,需要考虑:

  • 动态库 or 静态库
  • 以什么方式暴露库的接口:全局函数、类的non-virtual成员函数、类的virtual函数。

有两个基本假设:

  • 代码有bug,会发布bug fixes
  • 代码会有新功能,要升级

针对第一个假设:

  • 如果要热修复,则只能使用动态库;
  • 否则使用静态库更容易部署。

针对第二个假设:

  • 如果第一步用的是使用动态库
    • 如果只是 修 bug,那么二进制库文件应该达到二进制兼容
    • 如果要新增功能,应该对用户代码友好
  • 如果第一步用的静态库,那么不会遇到上面的问题。
虚函数作为库的接口的两大用途
  • 调用,库提供某个功能,以虚函数为接口暴露给客户端代码。
  • 回调,事件通知,重写虚函数作为回调时使用的函数。(该方法已过时,可以利用bind这种做法)
虚函数作为接口的弊端

要实现二进制兼容性十分困难,一旦发布,不能修改,只能重写编译。

比如如果要新增几个接口(虚函数),为了保证二进制兼容性,只能将新函数放到 interface的接口,这么做既丑陋又危险(一旦被继承)。

动态库接口的推荐做法

(思路,看不到库中头文件的具体实现更容易实现二进制兼容性,具体来说是避免看见头文件类中的数据成员,知道数据成员了就知道类的大小,将来增加数据成员后使用sizeof会出错,虽然)

Pimpl方法:类中包含一个指向某个具体类型的指针。实现定义在该具体类型的源文件中。(代码见cpp/muduo/chapter11/pimpl),这样可以隐藏Impl类,任何类都看不到该类,

  • 当修改Impl类时,不会导致其依赖于Impl的类重新编译(Person除外,毕竟Impl就定义在Person文件里面的)
  • 减少了编译依赖,
  • 动态库的使用范围有限,做好版本管理即可。(头文件和类分为用户可见部分和不可见部分,针对用户可见部分,需要注意二进制兼容性)
  • 使用 pimpl 方法,多使用 non-member non-friend函数来作为接口。

std::functionstd::bind 代替虚函数

作者推荐避免使用继承以及多态。一旦要修改某个基类,会牵一发而动全身。某个物体的类型也不是固定的,可能发生变化。

c++11之前,事件回调是通过继承基类,覆写虚函数,然后创建对象,将指针或应用注册到网络库中,如何管理这些类的生命周期是一个问题。

c++11之后,事件回调可以通过std::function 以及 std::bind来实现,这种方式不用担心对象的生存期。

对程序库的影响

程序库的设计不应该带来不必要的限制,比如继承,友元。这给程序设计带来了很大的耦合性。

当实现继承时,需要仔细思考若干对象之间的关系,做一个分类,然而,即使是在现实生活中,对于很多事物的分类都没有一个定论,比如,企鹅不会飞但是鸟。采样继承这种方法,意味着其子类的函数参数、名字等都确定了,这增大了很多耦合性。后期一旦要修bug,需要完成的工作量是巨大的。

iostream 的用途以及局限

int64类型在不同的系统,比如linux,windows上的实现不同,在linux下使用printf打印应该使用 %ld,而windows下应该使用%lld,为了避免在不同平台之间的不兼容性,要用 <inttypes.h> 里的宏PRId64,来实现安全打印。

平台/ABI int long long long int64_t 实现
Linux x86_64 (LP64) 32 位 64 位 64 位 long
Windows x64 (LLP64) 32 位 32 位 64 位 long long
32 位平台(x86, ARMv7) 32 位 32 位 64 位 long long
iostream的设计初衷

为了实现可拓展的类型安全的IO机制。

可拓展:可拓展到自定义类型,可继承iostream实现自己的stream。都是通过函数重载实现的。

作者觉得iostream不太行,会基本用法就行了。

  • iostream不具备线程安全性,即使单个operator<<是线程安全的,多个<<也不是线程安全的,因为本质上还是多个函数调用。不适合多线程环境。而stdio函数是线程安全的。
  • istream不适合输入带格式的数据
  • ostream格式化输出繁琐,且写死在代码中

作者认为很多操作都是臆造抽象:比如iostream, java的InputStream那一套。表面上符合面对对象原则,显示情况要残酷的多。比如,不同的IO之间的差异很大,共性很少,这就导致了硬要用面对对象,则基类太瘦的情况。

值语义与数据抽象

值语义:当前的类型是个值,拷贝之后的对象与原对象是脱离的。

对象语义(引用语义):禁止对象拷贝,直接赋值本质上是拷贝引用。

值语义很好管理生命周期,一旦使用对象语义,就需要担心使用的对象是否被释放(bug的一大来源)。

值语义与标准库

标准库涉及到push,pop等,要求放入的类型都具有值语义,即可以赋值的类型(删除了拷贝/移动构造函数的不行)。C++类通常会自动提供拷贝构造函数,可以自行禁止。

什么是数据抽象

隐藏数据的内部表示,只暴露对数据进行操作的接口。

数据抽象是针对“数据”的,意味着 ADT class应该可以拷贝。而面对对象和基于对象应该和对象语义,靠引用指针来传递。

写项目时,想清楚是对象语义还值语义,一般来说一个项目中,至于少量的class是值语义,大多数类是对象语义(使用noncopyable,使用引用指针来传递)。

作者给出的经验

用异或交换变量是低效的

带来了更多的数据读写,数据处理操作,也没有节约什么内存。更好的做法是使用std::reverse

避免重载::operator new()

内存分配的基本要求是不重不漏,从哪分配的从哪释放。(将申请的内存交给其他库时要小心了,比如标准库中的vector 自带了alloc,dealloc来处理内存)

为什么重载::operator new()
重载::operator new()的困境

.a 文件本质上是多个目标文件(.o)的归档(archive)。当你使用 ar 等工具将 .o 文件打包成 .a 文件时,链接器还未介入

.so 文件是一个可以独立运行的、自包含的二进制文件。它在生成时,必须经过一次链接

规则1:不能在library中重载,这会导致其他使用了该库的程序被迫使用该重载方式。

在单元测试中mock(模拟)系统调用

通过依赖注入实现单元测试。针对一个被测对象,需要有一个模拟对象来为其提供下层服务。然后将该模拟对象注入到被测程序中。

系统函数的依赖注入

如果不涉及系统调用,那么直接模拟下层代码的接口,写一个模拟对象就行了。但如果需要模拟系统调用呢。

  • 方法一是把需要使用系统调用的的地方均换为对某个类虚函数的调用,这样来实现对模拟类的访问。(其实无需多态)
  • 方法二是把需要使用系统调用的的地方替换为对某个名称空间中的某个函数。这样将被测程序链接到不同的文件即可实现测试或者正常运行。
链接器垫片

上面的做法需要在一开始编码时就考虑到需要进行单元测试,并使用对应的接口。如果一开始没有考虑到进行单元测试呢?

利用链接期垫片。在单元测试程序里面实现一个自己的系统调用函数,而因为libc基本上都是动态连接的。默认情况下会使用我们自己的函数。(这种方法针对动态链接的库应该都有用)。

慎用匿名namespace

主要作用是:

限制符号的作用域到当前翻译单元(源文件)内部,避免与其他翻译单元中的同名符号发生冲突。(相当于static)

有利于版本管理的代码格式

  • diff通常显示改动位置上下三行的代码,使用//会比/**/清晰很多。

  • 一行只定义一个变量

  • 函数的参数大于等于三个时,每个参数占一行(不然就得数逗号了)

  • 类的初始化列表也类似

  • 成员函数不会应该处于类或者namespace中而增加缩进,这样有利于diff -p显示成员函数名称。(默认找的是前面的第一个顶格写的函数)

  • 使用static_cast而非c风格的类型转换,有助于我们grep查找代码。