文章目录
学习多线程编程面临的最大思维方式的转变有两点:
-
当前线程可能被切换出去, 或者说被抢占(preempt)了。当线程被切换出去回来继续执行下一条语句(指令)的时候, 全局数据(包括当前进程在操作系统内核中的状态)可能已经被其他线程修改了。
-
多线程程序中事件的发生顺序不再有全局统一的先后关系。
- 在引入适当同步后, 事件之间才有了happens-before关系。
- 必须通过适当的同步来让当前线程能看到其他线程的事件的结果(被操作系统切换出去得越多, 执行越慢)。
- 加延迟是不能解决线程间同步的问题的。
- pthread_create()是具有happens-before语义的。
一、基本线程原语的选用
POSIX threads的函数有110多个, 真正常用的不过十几个:
- 2个:线程的创建(pthread_create)和等待结束(pthread_join)。封装为muduo::Thread。
- 4个:mutex的创建(pthread_mutex_init)、销毁(pthread_mutex_destroy)、加锁(pthread_mutex_lock)、解锁(pthread_mutex_unlock)。封装为muduo::MutexLock。
- 5个:条件变量的创建(pthread_cond_init)、销毁(pthread_cond_destroy)、等待(pthread_cond_wait)、通知(pthread_cond_signal)、广播(pthread_cond_broadcast)。封装为muduo::Condition。
【不建议使用】:
- pthread_rwlock, 读写锁应慎用。
- sem_*, 避免使用信号量(semaphore), 它的功能与条件变量重合, 但容易用错。
- pthread_{cancel, kill}, 程序中出现了他们, 则通常意味着出现了设计问题。
多线程系统编程的难点不在于学习线程原语(primitives), 而在于理解多线程与现有的C/C++库函数和系统调用的交互关系, 以进一步学习如何设计并实现线程安全且高效的程序。
二、C/C++系统库的线程安全性
【线程安全遵循一个基本原则】:
-
凡是非共享的对象都是彼此独立的,如果一个对象从始至终只被一个线程用到, 那么就是线程安全的。
-
共享对象的read-only操作是安全的, 前提是不能有并发的写操作。
C++的标准库容器和std::string都不是线程安全的,只有std::allocator保证是线程安全的。一方面的原因是为了避免不必要的性能开销,另一方面的原因是单个成员函数的线程安全并不具备可组合性。
safe_vector<int> vec; //全局可见
if(!vec.empty()) //没有加锁保护
{
int x = vec[0]; //这两步在多线程下是不安全的
}
C++标准库中的绝大多数泛型算法是线程安全的, 因为这些都是无状态纯函数;C++的iostream不是线程安全的, 因为流式输出等价于两个函数调用, 即便ostream::operator<<()做到了线程安全, 也不能保证其他线程不会在两次函数调用之前向stdout输出其他字符。
三、Linux上的线程标识
POSIX threads库提供了pthread_self函数用于返回当前线程的标识符, 其类型为pthread_t;但是pthread_t值只在进程内有意义, 与操作系统的任务调度之间无法建立有效关联, 比如说在/proc文件系统中找不到pthread_t对应的task。
glibc的Pthreads实现实际把pthread_t用作一个结构体指针(它的类型是unsigned long), 指向一块动态分配的内存, 而这块内存可以反复使用;这就造成了pthread_t的值很容易重复,Pthreads只保证统一进程之内, 同一时刻各个线程的id不同,不能保证同一进程先后多个线程具有不同的id。
在Linux系统上用gittid系统调用的返回值来作为线程id,好处有:
-
它的类型是pid_t, 其值通常是一个小整数, 便于在日志中输出。
-
在现代Linux系统中, 它直接表示内核的任务调度id, 因此在/proc文件系统中可以轻易找到对应项: /proc/tid或/proc/task/tid。
-
在其他系统工具中也容易定位到具体某一个线程, top可以找出CPU使用率最高的线程id, 再根据日志判断到低那个线程在耗用CPU。
-
任何时刻都是全局唯一的, 并且由于Linux分配新pid采用递增轮回法, 短时间内启动的多个线程也会具有不同的线程id。
四、线程的创建与销毁的守则
【线程的创建和销毁需要遵循几条简单的原则】:
-
程序库不应该在未提前告知的情况下创建自己的“背景线程”。
-
尽量用相同的方式创建线程,例如muduo::Thread。
-
不要为每个计算任务, 每次请求去创建线程, 一般也不会为每个网络连接创建线程,在进入main()函数之前不应该启动线程。
-
程序中线程的创建最好能在初始化阶段全部完成,在程序运行期间不再创建或销毁线程,可以使用线程池等方法。
【线程的销毁有几种方式】:
-
自然死亡:从线程主函数返回,线程正常退出。
-
非正常死亡:从线程主函数抛出异常或线程触发segfault信号等非操作。
-
自杀:在线程中调用pthread_exit()来立刻退出线程。
-
他杀:其他线程(必须是同一个进程中的其他线程)调用pthread_cancel()来强制终止某个线程。
五、善用__thread关键字
__thread是GCC内置的线程局部存储设施(thread local storage);它的实现非常高效, 比pthread_t快得多;__thread变量的存取效率可与全局变量相比。
【__thread使用规则】:
-
只能用于修饰POD类型(plain old data), 不能修饰class类型, 因为无法自动调用构造函数和析构函数。
-
__thread可以用于修饰全局变量、函数内的静态变量, 但是不能修饰函数的局部变量或者是class的普通成员变量。
-
__thread变量的初始化只能用编译期常量。
-
__thread变量是每个线程有一份独立实体, 各个线程的变量值互不干扰。
-
__thread变量还可以修饰值可能会改变, 带有全局性, 但是又不值得用全局锁保护的变量。
六、多线程与IO
操作文件描述符的系统调用本身是线程安全的, 我们也不用担心多个线程同时操作文件描述符会造成进程崩溃或内核崩溃。多个线程同时操作同一个socket文件描述符需要考虑的情况如下:
-
如果一个线程正在阻塞地read某个socket,而另一个线程close了此socket。
-
如果一个线程正在阻塞地accept某个listening socket,而另一个线程close了此socket。
-
一个线程正准备read某个socket,而另一个线程close了此socket,第三个线程又恰好open了另一个文件描述符,而fd号码刚好和之前的socket相同。
【多线程程序应该遵循的原则】:
-
每个文件描述符只由一个线程操作,从而轻松解决消息收发的顺序性问题,也避免了关闭文件描述符的各种race condition。
-
epoll也遵循相同的原则:为了稳妥起见, 我们应该把对同一个epoll fd的操作(添加, 删除, 修改, 等待)都放到同一个线程中执行。一般程序不会直接使用epoll, read, write, 这些底层操作都由网络库代劳了。
对于磁盘文件, 在必要的时候多个线程可以同时调用pread/pwrite来读写同一个文件;对于UDP,由于协议本身保证消息的原子性,在适当的条件下(比如消息之间彼此独立)可以多个线程同时读写同一个UDP文件描述符。
七、用RAII包装文件描述符
Linux的文件描述符(file descriptor)是小整数, 在程序刚刚启动的时候, 0是标准输入, 1是标准输出, 2是标准错误,POSIX标准要求每次新打开文件(含socket)的时候必须使用当前最小可用文件描述符号码。
RAII:用socket对象包装文件描述符, 所有对此文件描述符的读写操作都通过此对象进行, 在对象的析构函数里关闭文件描述符。
现代C++的一个特点是对象生命期管理的进步, 体现在不需要手工delete对象。muduo库使用shared_ptr来管理tcpConnection的生命期, 这是唯一一个采用引用计数方式管理生命期的对象。
八、多线程与fork()
【多线程和fork()之间的问题】:
-
多线程和fork()的协作性很差。
-
fork()一般不能在多线程中调用, 因为Linux的fork()只克隆当前线程的thread of control, 不克隆其他线程。
-
fork()之后, 除了当前线程之外, 其他线程都消失了。
-
也就是说不能一下子fork出一个和父进程一样的多线程子进程。
因此, 唯一安全的做法是在fork()之后立即调用exec()执行另一个程序, 彻底隔断子进程与父进程的联系。
九、多线程与signal
在多线程程序中,使用signal的第一原则是不要使用signal:
-
不要用signal作为IPC的手段。
-
也不要使用基于signal实现的定时函数。
-
不主动处理各种异常信号。
-
在没有别的替代方法的情况下,把异步信号转换为同步的文件描述符事件。
十、总结
-
线程是宝贵的,一台机器上不应该同时运行几百个、几千个用户线程,这会大大增加内核scheduler的负担。
-
线程的创建和销毁是有代价的,一个程序最好在一开始创建所需的线程,并一直反复使用。
-
每个线程应该有明确的职责,例如IO线程、计算线程。
-
线程之间的交互应尽量简单,理想情况下,线程之间只用消息传递(例如阻塞队列)方式交互。如果必须要用锁,那么最好避免一个线程同时拥有两把或者更多的锁,这样可以防止死锁。
-
要预先考虑清楚一个mutable shared对象将会暴露给哪些线程,那么线程是读还是写,读写有无可能并发进行。