4.1 基本线程原语的选用
11个最基本的Pthreads函数是:
2个:线程的创建和等待结束(join)。封装为muduo::Thread。
4个:mutex的创建、销毁、加锁、解锁。封装为muduo::MutexLock。
5个:条件变量的创建、销毁、等待、通知、广播。封装为muduo::Condition。
这三样东西(thread、mutex、condition)可以完成任何多线程编程任务。当然一般不会直接使用它们(mutex除外),而是使用更高层的封装,例如 mutex::ThreadPool 和 mutex::CountDownLatch 等。
4.2 C/C++ 系统库的线程安全性
基本原则:凡是非共享的对象都是彼此独立的,如果一个对象从始至终只被一个线程用到,那么它就是安全的。共享的对象的 read-only 操作是安全的, 前提是不能有并发的写操作。
4.3 Linux上的线程标识
POSIX threads 库提供了pthread_self 函数用于返回当前进程的标识符,其类型为pthread_t。但是有一系列问题:
并且glibc的Pthreads实现实际上把pthread_t用作一个结构体指针,指向一块动态分配的内存,这块内存反复使用。这就造成pthread_t的值很容易重复。Pthreads只保证同一进程内各个线程的id不同;不能保证同一进程先后多个进程具有不同的id。
因此,pthread_t并不适合用于程序中对线程的标识符。
在Linux上,使用gettid系统调用的返回值作为线程id。 好处有:
muduo::CurrentThread::tid() 采取的办法是用 __thread 变量来缓存 gettid()的返回值。这样只有在本线程第一次调用的时候才进行系统调用,以后直接从 thread local 缓存的线程id 拿到结果。
4.4 线程的创建与销毁的守则
线程创建的几条简单的原则:
1.程序库不应该在未提前告知的情况下创建自己的“背景线程”。
2.尽量用相同的方式创建线程,例如 muduo::Thread。
3.在进入main()函数之前不应该启动线程。
4.程序中线程的创建最好能在初始化阶段全部完成。
一台机器可以同时并行运行的线程数目受限于CPU的数目,所以根据CPU的数目来设置工作线程的数目。
线程销毁的几种方式:
1.自然死亡。 从线程主函数返回,线程正常退出。
2.非正常死亡。 从线程主函数抛出异常或线程出发segfault信号等非法操作。
3.自杀。 在线程中调用pthread_exit() 来立刻退出线程。
4.他杀。 其他线程调用pthread_cancel() 来强制终止某个线程。
如果能做到前面提到的 “程序中线程的创建最好能在初始化阶段全部完成“,则线程是不必销毁的,伴随进程一直运行,彻底避开了线程安全退出可能面临的各种困难,包括Thread对象生命期管理,资源释放等等。
4.5 善用 __thread 关键字
__thread变量使每个线程有一份独立实体,各个线程的变量值互不干扰。除了这个主要用途,还可以修饰那些 “值可能会变,带有全局性,但是不值得用全局锁保护” 的变量。
__thread使用规则:只能修饰POD类型(类似整型指针的标量,不带自定义的构造、拷贝、赋值、析构的类型,二进制内容可以任意复制memset,memcpy,且内容可以复原),不能修饰class类型,因为无法自动调用构造函数和析构函数,可以用于修饰全局变量,函数内的静态变量,不能修饰函数的局部变量或者class的普通成员变量,且__thread变量值只能初始化为编译器常量。
4.6多线程与IO
多线程应该遵循的原则是:
每个文件描述符只由一个线程操作,从而轻松解决消息收发的顺序性问题,也避免了关闭文件描述符的各种 race condition。
一个线程可以操作多个文件描述符,但一个线程不能操作别的线程拥有的文件描述符。
这条规则有两个例外:
对于磁盘文件,在必要的时候多个线程可以同时调用pread()/pwrite()来读写同一个文件;
对于UDP,由于协议本身保护消息的原子性,在适当的条件下可以多个线程同时读写同一个UDP文件描述符。
4.7 用RAII包装文件描述符
用Socket对象包装文件描述符,所有对此文件描述符的读写操作都通过此对象进行,在对象的析构函数里关闭文件描述符。只要Socket对象还活着,就不会有其他Socket对象跟它有一样的文件描述符,也就不可能串话。
4.9 多线程与fork()
多线程与fork()的协作性很差。fork一般不能在多线程程序中调用,因为Linux的fork()只克隆当前线程的 thread of control,不克隆其他线程。fork之后,除了当前线程之外,其他线程都消失了。也就是说不能一下子fork()出一个和父进程一样的多线程子进程。
fork()之后子进程中只有一个线程,其他线程都消失了,这就造成一个危险的局面。其他线程可能正好处于临界区之内,持有某个锁,而它突然死亡,再也没有机会去解锁了。如果子进程试图再对同一个mutex加锁,就会立刻死锁。在fork()之后,子进程就相当于处于signal handler之中。
4.10 多线程与 signal
Linux/Unix的信号与多线程水火不容!在多线程程序中,使用signal的第一原则是 不要使用signal
小结:
编写多线程C++程序的原则如下:
1.线程是宝贵的,一个程序可以使用几个或十几个线程。
2.线程的创建和销毁是有代价的,一个程序最好在一开始创建所需的线程,并一直反复使用。不要在运行期间反复创建、销毁线程。
3.每个线程应该有明确的职责,例如IO线程( 运行EventLoop::loop(),处理IO事件 ),计算线程(位于ThreadPool中,负责计算)等等。
4.线程之间的交互应该尽量简单,理想情况下,线程之间只用消息传递方式交互。如果必须用锁,那么最好避免一个线程同时持有两把或更多的锁,这样可彻底防止死锁。
5.要预先考虑清楚一个 mutable shared 对象将会暴露给哪些线程,每个线程是读还是写, 读写有无可能并发进行。