多线程程序的正确性不能依赖于任何一个线程的执行速度不能通过原地等待(**sleep()**)来假定其他线程的事件已经发生,而必须通过适当的同步来让当前线程能看到其他线程的事件的结果。

自从有了线程,errno不再是一个全局变量,因为每个线程可能会执行不同的系统库函数。现在 Linux glibc**errno**定义为一个宏errno是一个左值,所以不能简单定义为某个函数的返回值,必须定义为对函数返回指针的解引用
image.png

不必担心系统调用的线程安全性,因为系统调用对于用户态程序来说是原子的
C++ 的iostream不是线程安全的,因为流式输出等价于几个函数调用:
image.png
即便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 变量。

image.png

4.4 线程的创建与销毁守则

最好在程序初始化阶段创建全部工作线程,在程序运行期间不再创建或销毁线程

main()函数之前不应该启动线程,因为会影响全局对象的安全构造。如果其中一个全局对象创建了线程,就会破坏初始化全局对象按照一定顺序的基本假设。有可能在后续改动后造成该线程访问未经初始化的全局对象,纠错困难。

不要为了每个计算任务,每次请求创建线程;一般也不会为每个网络连接创建线程,除非并发连接数与 CPU 数相近。
如果有实时性要求,线程数目不应该超过 CPU 数目,这样可以基本保证新任务总能及时被执行,因为总有 CPU 是空闲的。

只让线程正常退出,不要强行终止线程(这样它没有机会清理资源也没有机会释放已经持有的锁,这种情况下其他线程对同一个 mutex 加锁就会死锁)。
如果确实需要强行终止一个耗时很长的计算任务,又不想在计算期间周期性检查某个全局退出标志,可以考虑把那一部分代码fork()为新的线程,这样的话**kill**一个进程比终止本进程内的线程安全的多
fork()的新进程与本进程的通信方式最好用文件描述符来收发数据(pipe / socketpair / TCP socket),儿不要用共享内存和跨进程的互斥器等 IPC,因为仍有死锁可能。

4.4.2 exit() 在C++中不是线程安全的

exit()在C++中的作用除了终止进程,还会析构全局对象和已经构造完的函数静态对象。有潜在的死锁可能。
image.png
如果有时候需要主动结束线程,可以使用_exit()系统调用,他不会试图析构全局对象
image.png
image.png

4.5 善用 __thread 关键字

__thread只能修饰 POD 类型,不能修饰 class 类型,因为无法自动调用构造函数和析构函数
__thread可以修饰全局变量函数内的静态变量不能修饰函数的局部变量或 class 的普通成员变量
__thread变量的初始化只能用编译期常量(constexpr):
image.png
__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

HINTTCP 连接的数据只能读一次,磁盘文件会移动当前位置
在多线程环境中,对文件描述符的操作可能会有意料之外的差错——使用 RAII 手法。用 Socket 对象包装文件描述符,所有对此文件描述符的读写操作都通过此对象进行。

服务端程序不应该关闭标准输出和标准错误(fd = 1 & fd = 2),因为有些三方库会在 urg 时往这两处打印出错信息。如果关闭的话,这两个 fd 就可能被网络连接占用,造成对方收到莫名其妙的数据。
应该将 stdout | stderr 重定向到磁盘文件(不要是 /dev/null),这样不会丢失关键的诊断信息:

  • shell 命令:
    • 输入重定向:

image.png

  • 输出重定向:

image.png

  • 编码方式:
    • 在启动服务程序时就关闭对应 fd,并立即打开重定向 fd;
    • 使用dup2()系统调用,oldfdnewfd都可以操作oldfd指向的文件。

image.png
003BDE59D32368CDF90C463D040629FF.png:这个问题最近在工作中也遇到了,生命周期不一样,不能贸然 delete,目前的做法是设置一个延迟销毁队列,现在 mentor 让我想一个别的解决方案,苦思中……

在非阻塞网络编程中,会遇到一种场景:
从某个 TCP 连接 A 收到一个请求,由于处理较为耗时,所以程序记住了该连接,先处理其他连接。但是在处理时,客户端断开连接 A,并创建新连接 B。假如程序只记住了文件描述符,response 就会发送给 B,造成串话。所以应该持有 TCP 连接的 TcpConnection 对象,保证在处理请求期间不会关闭连接的文件描述符。或者持有 TcpConnction 对象的弱引用(weak_ptr),这样能知道 socket 连接在处理请求时连接是否关闭。

4.8 RAII 与 fork()

假如程序会fork(),就造成了资源管理的困难:
image.png
如果 Foo class 封装了某种没有被子进程继承的资源,那么Foo::doit()的功能在子进程中就是错乱的。这点无法自动预防,因为不能每次申请一个资源就调用一次pthread_atfork()

4.9 多线程与 fork()

fork()一般不能再多线程程序中调用,因为 Linux 的fork()只克隆当前线程的 thread of control,不克隆其他线程。所以fork()之后,除了当前线程其他线程都消失了。
这就有可能出现一种情况:消失的线程有的正处于临界区内持有着某个锁,而它突然G了,就没有机会解锁了。如果子进程试图对同一个mutex加锁就会立刻死锁。

**fork()**之后,子进程相当于处于 signal handler 中不能调用线程安全的函数(除非是可重入的),只能调用异步信号安全的函数。比如fork()后,子进程不能调用:
image.png
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_NONBLOCKFD_CLOEXEC,例如:
image.png
文件描述符默认是阻塞的

FD_CLOEXEC功能是:程序在exec()时,进程会自动关闭该文件描述符(而 fd 默认是被子进程继承)。
说明fork()的主要目的已经不再是创建 work process 并通过共享的文件描述符与父进程保持通信,而是为了exec()执行新的程序。

总结

image.png