多线程程序的正确性不能依赖于任何一个线程的执行速度,不能通过原地等待(**sleep()**
)来假定其他线程的事件已经发生,而必须通过适当的同步来让当前线程能看到其他线程的事件的结果。
自从有了线程,errno
不再是一个全局变量,因为每个线程可能会执行不同的系统库函数。现在 Linux glibc 把**errno**
定义为一个宏,errno
是一个左值,所以不能简单定义为某个函数的返回值,必须定义为对函数返回指针的解引用。
不必担心系统调用的线程安全性,因为系统调用对于用户态程序来说是原子的。
C++ 的iostream
不是线程安全的,因为流式输出等价于几个函数调用:
即便ostream::operator<<()
做到了线程安全,也不能保证其他线程不会在两次函数调用之前向**std::out**
输出其他字符。如果改用printf
达到安全性和输出的原子性,就等于使用了全局锁,任何时刻只能有一个线程调用printf
,比较低效。
4.3 Linux 上的线程标识
**pthread_self()**
用于返回当前进程的标识符,类型为pthread_t
,不一定是一个数值类型,可能是一个结构体,因此提供有thread_equal()
函数比较两个标识符是否相等。会带来一系列问题:
- 无法打印输出
pthread_t
- 无法比较
pthread_t
或计算其hash值; - 无法定义一个非法的
pthread_t
值,用于表示绝对不可能存在的线程 id; **pthread_t**
只在进程内有意义,与操作系统的任务调度之间无法建立有效关联。 比方说在 /proc 文件系统中找不到pthread_t
对应的 task。
glibc 实际上吧pthread_t
用作一个结构体指针(unsigned long 类型)指向一块动态分配内存,这块内存反复使用。所以只能保证同一进程内、同一时刻的各个线程id不同,不能保证同一进程内,先后多个线程具有不同id。
因此建议使用gettid()
系统调用的返回值作为线程 id:
- 类型是
pid_t
,通常是一个小整数; - 直接表示内核的任务调度 id,因此在 /proc 文件系统中可以找到对应项:/proc/tid 或 /proc/pid/task/tid;
- 任何时刻都是全局唯一的;
- 0 是非法值,第一个进程 init 的 pid 是 1。
如果每次都执行一次系统调用会有点浪费,所以muduo::CurrentThread::tid()
采取的办法是:使用**__thread**
变量来缓存**gettid()**
的返回值,这样只在本线程第一次调用的时候才进行系统调用,以后都直接从 thread local 缓存的线程 id 拿到结果。
HINT:__thread
只能用于 POD 变量。
4.4 线程的创建与销毁守则
最好在程序初始化阶段创建全部工作线程,在程序运行期间不再创建或销毁线程。
在main()
函数之前不应该启动线程,因为会影响全局对象的安全构造。如果其中一个全局对象创建了线程,就会破坏初始化全局对象按照一定顺序的基本假设。有可能在后续改动后造成该线程访问未经初始化的全局对象,纠错困难。
不要为了每个计算任务,每次请求创建线程;一般也不会为每个网络连接创建线程,除非并发连接数与 CPU 数相近。
如果有实时性要求,线程数目不应该超过 CPU 数目,这样可以基本保证新任务总能及时被执行,因为总有 CPU 是空闲的。
只让线程正常退出,不要强行终止线程(这样它没有机会清理资源,也没有机会释放已经持有的锁,这种情况下其他线程对同一个 mutex 加锁就会死锁)。
如果确实需要强行终止一个耗时很长的计算任务,又不想在计算期间周期性检查某个全局退出标志,可以考虑把那一部分代码fork()
为新的线程,这样的话**kill**
一个进程比终止本进程内的线程安全的多。fork()
的新进程与本进程的通信方式最好用文件描述符来收发数据(pipe / socketpair / TCP socket),儿不要用共享内存和跨进程的互斥器等 IPC,因为仍有死锁可能。
4.4.2 exit() 在C++中不是线程安全的
exit()
在C++中的作用除了终止进程,还会析构全局对象和已经构造完的函数静态对象。有潜在的死锁可能。
如果有时候需要主动结束线程,可以使用_exit()
系统调用,他不会试图析构全局对象。
4.5 善用 __thread 关键字
__thread
只能修饰 POD 类型,不能修饰 class 类型,因为无法自动调用构造函数和析构函数。__thread
可以修饰全局变量、函数内的静态变量,不能修饰函数的局部变量或 class 的普通成员变量。__thread
变量的初始化只能用编译期常量(constexpr):__thread
变量是每个线程有一份独立实体,各个线程的变量值互不干扰。还可以修饰那些“值可能会变,带有全局性,但是又不值得用全局锁保护”的变量。
4.6 多线程与 IO
操作文件描述符的系统调用本身是线程安全的,不用担心多个线程同时操作文件描述符造成进程、内核崩溃。但是这种情况很麻烦,得不偿失。需要考虑:
- 如果一个线程正在阻塞地 read 某个 socket,而另一个线程 close 了此 socket;
- 如果一个线程正在阻塞地 accept 某个 listening socket,而另一个线程 close 了此 socket;
- 一个线程正准备 read 某个 socket,而另一个线程 close 了此 socket;第三个线程又恰好 open 了另一个文件描述符,其 fd 正好与前面的 socket 相同。
假设不考虑关闭文件描述符,只考虑读和写。因为 socket 读写的特点是不保证完整性,读 100 字节可能只返回 20 字节,写操作亦然:
- 如果两个线程同时 read 同一个 TCP socket,两个线程几乎同时各自收到一部分数据,如何把数据拼成完整消息?如何知道哪部分数据先到达?
- 如果两个线程同时 write 同一个 TCP socket,每个线程都只发出去半条消息,接收方收到数据如何处理?
- 对于非阻塞 IO,情况一样,而且收发消息的完整性和原子性不能用锁来保证,会阻塞其他 IO 线程。
因此,理论上只有 read 和 write 可以分到两个线程,因为 TCP socket 是双向 IO。问题是,将 read 和 write 拆成两个线程值得吗?
以上都是网络 IO 情况,那么多线程可以加速磁盘 IO 吗?首先要避免 lseek / read 的竞态条件。用多个线程 read 或 write 同一个磁盘上的同一个文件和多个文件都不一定会提速,因为每块磁盘都有一个操作队列,多个线程的读写请求到了内核是排队执行的。只有在内核缓存了大部分数据的情况下,多线程读这些热数据才可能比单线程快。
一般而言一个文件只由一个进程中的线程来读写,是显然正确的。
对于 UDP 来说,协议本身保证消息的原子性,在适当条件下(消息之间彼此独立)可以多个线程同时读写同一个 UDP 文件描述符。
4.7 用 RAII 包装文件描述符
Linux 文件描述符是小整数,POSIX 要求新打开的文件时必须使用当前最小可用的文件描述符号码:
- 0:标准输入 stdin
- 1:标准输出 stdout
- 2:标准错误 sterr
HINT:TCP 连接的数据只能读一次,磁盘文件会移动当前位置。
在多线程环境中,对文件描述符的操作可能会有意料之外的差错——使用 RAII 手法。用 Socket 对象包装文件描述符,所有对此文件描述符的读写操作都通过此对象进行。
服务端程序不应该关闭标准输出和标准错误(fd = 1 & fd = 2),因为有些三方库会在 urg 时往这两处打印出错信息。如果关闭的话,这两个 fd 就可能被网络连接占用,造成对方收到莫名其妙的数据。
应该将 stdout | stderr 重定向到磁盘文件(不要是 /dev/null),这样不会丢失关键的诊断信息:
- shell 命令:
- 输入重定向:
- 输出重定向:
- 编码方式:
- 在启动服务程序时就关闭对应 fd,并立即打开重定向 fd;
- 使用
dup2()
系统调用,oldfd
、newfd
都可以操作oldfd
指向的文件。
:这个问题最近在工作中也遇到了,生命周期不一样,不能贸然 delete,目前的做法是设置一个延迟销毁队列,现在 mentor 让我想一个别的解决方案,苦思中……
在非阻塞网络编程中,会遇到一种场景:
从某个 TCP 连接 A 收到一个请求,由于处理较为耗时,所以程序记住了该连接,先处理其他连接。但是在处理时,客户端断开连接 A,并创建新连接 B。假如程序只记住了文件描述符,response 就会发送给 B,造成串话。所以应该持有 TCP 连接的 TcpConnection 对象,保证在处理请求期间不会关闭连接的文件描述符。或者持有 TcpConnction 对象的弱引用(weak_ptr),这样能知道 socket 连接在处理请求时连接是否关闭。
4.8 RAII 与 fork()
假如程序会fork()
,就造成了资源管理的困难:
如果 Foo class 封装了某种没有被子进程继承的资源,那么Foo::doit()
的功能在子进程中就是错乱的。这点无法自动预防,因为不能每次申请一个资源就调用一次pthread_atfork()
。
4.9 多线程与 fork()
fork()
一般不能再多线程程序中调用,因为 Linux 的fork()
只克隆当前线程的 thread of control,不克隆其他线程。所以fork()
之后,除了当前线程其他线程都消失了。
这就有可能出现一种情况:消失的线程有的正处于临界区内,持有着某个锁,而它突然G了,就没有机会解锁了。如果子进程试图对同一个mutex
加锁就会立刻死锁。
在**fork()**
之后,子进程相当于处于 signal handler 中,不能调用线程安全的函数(除非是可重入的),只能调用异步信号安全的函数。比如fork()
后,子进程不能调用:man 2 fork
片段如下:
After a fork() in a multithreaded program, the child can safely call only async-signal-safe functions (see signal-safety(7)) until such time as it calls execve(2).
所以唯一安全的做法就是在fork()
之后调用 exec族函数执行另一个程序,彻底隔断父子进程间的关系。
4.10 多线程与 signal
siganl 会打断正在运行的 thread of control,在 signal handler 中只能调用异步信号安全函数,即可重入函数(reentrant)。
如果 signal handler 中需要修改全局数据,那么变量必须是**sig_atomic_t**
类型。否则被打断的函数在恢复执行后可能不能立刻看到 signal handler 改动后的数据。因为编译器可能假定该变量不会被他处修改,从而优化了内存访问。
不要在程序中使用signal
。现代 Linux 的做法是采用signalfd
将信号直接转换为文件描述符事件,从而从根本上避免使用 signal handler。
4.11 Linux 新增系统调用的启示
从 Linux 内核2.6.27 起,凡是会创建文件描述符的 syscall 一般都增加了额外的 flags 参数,可以直接指定O_NONBLOCK
和FD_CLOEXEC
,例如:
文件描述符默认是阻塞的。
FD_CLOEXEC
功能是:程序在exec()
时,进程会自动关闭该文件描述符(而 fd 默认是被子进程继承)。
说明fork()
的主要目的已经不再是创建 work process 并通过共享的文件描述符与父进程保持通信,而是为了exec()
执行新的程序。