C++多线程系统编程精要
- 学习多线程编程面临的最大思维方式的转变有两点:
- 当前线程可能被切换出去, 或者说被抢占(preempt)了;
- 多线程程序中事件的发生顺序不再有全局统一的先后关系;
- 当线程被切换出去回来继续执行下一条语句(指令)的时候, 全局数据(包括当前进程在操作系统内核中的状态)可能已经被其他线程修改了;
- 访问非法地址, 会产生段错误(segfualt);
- 在没有适当的同步的情况下, 多个CPU上运行的多个线程中的事件发生先后顺序是无法确定的;
- 在引入适当同步后, 事件之间才有了happens-before关系;
- 必须通过适当的同步来让当前线程能看到其他线程的事件的结果(被操作系统切换出去得越多, 执行越慢);
- 加延迟是不能解决线程间同步的问题的;
- pthread_create()是具有happens-before语义的;
基本线程原语的选用
- POSIX threads的函数有110多个, 真正常用的不过十几个;
- 2个: 线程的创建和等待结束(join);
- 4个: mutex的创建、销毁、加锁、解锁;
- 5个: 条件变量的创建、销毁、等待、通知、广播;
- 利用(thread, mutex, condition)可以完成任何多线程编程任务;
- 一般也不会直接使用他们, 而是使用更层的封装;
- threadPool和countDownLatch;
- 酌情使用的原语: pthread_once, 其实不如直接用全局变量;
- pthread_key*, 可以考虑用__thread替换之;
- 不建议使用:
- pthread_rwlock, 读写锁应慎用;
- sem_*, 避免使用信号量(semaphore), 它的功能与条件变量重合, 但容易用错;
- pthread_{cancel, kill}, 程序中出现了他们, 则通常意味着出现了设计问题;
- 多线程系统编程的难点不在于学习线程原语(primitives), 而在于理解多线程与现有的C/C++库函数和系统调用的交互关系, 以进一步学习如何设计并实现线程安全且高效的程序;
C/C++系统库的线程安全性
- 新版的C/C++标准(C11和C++11)规定了程序在多线程下的语义, C++11还定义了一个线程库(std::thread);
- 对标准而言, 关键不是定义线程库, 而是规定内存模型(memory model);
- 特别是规定一个线程对某个共享变量的修改何时能被其他线程看到, 这称为内存序(memory ordering)或者内存能见度(memory visibility);
- Linux操作系统内核本身也可以是抢占的(preemptive);
- Unix系统库(libc和系统调用)的接口风格在20世纪70年代早期确定的;
- 现在Linux glibc把errno定义为一个宏, 注意errno是一个lvalue(左值);
- 不能简单定义为某个函数的返回值, 而必须定义为对函数返回指针的dereference;
- 最早的SGI STL自己定制了内存分配器, 而现在g++自带的STL已经直接用malloc来分配内存, std::allocator已经变成了鸡肋;
- 不用担心系统调用的线程安全性, 因为系统调用对于用户态程序来说是原子性的;
- 但是要注意系统调用对于内核状态的改变可能影响其他线程;
- system、getenv/putenv/setenv等函数都是线程不安全的;
- FILE*系列函数是安全的;
- 尽管两个函数都是线程安全的, 但是两个函数放在一起就不是线程安全的了(比如fseek和fread);
- 可以用flockfile(FILE)和funlockfile(FILE)函数来显示地加锁;
- 并且由于FILE*的锁是可重入的, 加锁之后再调用fread()不会造成死锁;
- 编写线程安全程序的一个难点在于线程安全是不可组合的(composable);
- 线程安全遵循一个基本原则:
- 凡是非共享的对象都是彼此独立的,如果一个对象从始至终只被一个线程用到, 那么就是线程安全的;
- 共享对象的read-only操作是安全的, 前提是不能有并发的写操作;
- C++标准库中的绝大多数泛型算法是线程安全的, 因为这些都是无状态纯函数;
- 只要输入区间是线程安全的, 那么泛型函数就是线程安全的;
- C++的iostream不是线程安全的, 因为流式输出等价于两个函数调用, 即便ostream::operator<<()做到了线程安全, 也不能保证其他线程不会在两次函数调用之前向stdout输出其他字符;
- 在多线程程序中高效的日志需要特殊设计;
Linux上的线程标识
- POSIX threads库提供了pthread_self函数用于返回当前线程的标识符, 其类型为pthread_t;
- pthread_t不一定是一个数值类型(整数或指针), 也有可能是一个结构体;
- Pthreads专门提供了pthread_equal函数用于对比两个线程标识符是否相等;
- pthread_t值只在进程内有意义, 与操作系统的任务调度之间无法建立有效关联, 比如说在/proc文件系统中找不到pthread_t对应的task;
- glibc的Pthreads实现实际把pthread_t用作一个结构体指针(它的类型是unsigned long), 指向一块动态分配的内存, 而这块内存可以反复使用;
- 这就造成了pthread_t的值很容易重复;
- Pthreads只保证统一进程之内, 同一时刻各个线程的id不同;
- 在Linux系统上用gittid系统调用的返回值来作为线程id, 好处有:
- 它的类型是pid_t, 其值通常是一个小整数, 便于在日志中输出;
- 在现代Linux系统中, 它直接表示内核的任务调度id, 因此在/proc文件系统中可以轻易找到对应项: /proc/tid或/proc/task/tid;
- 在其他系统工具中也容易定位到具体某一个线程, top可以找出CPU使用率最高的线程id, 再根据日志判断到低那个线程在耗用CPU;
- 任何时刻都是全局唯一的, 并且由于Linux分配新pid采用递增轮回法, 短时间内启动的多个线程也会具有不同的线程id;
- 0是非法值, 因为操作系统第一个进程init的pid是1;
- 每次都调用一次gettid很浪费, 可以采用__thread变量来缓存gettid的返回值, 这样只有在本线程第一次调用的时候才才进行系统调用, 以后直接从thread_local缓存的线程id拿到结果, 效率无忧;
- 用pthread_atfork()注册一个回调, 用于清空缓存的线程id;
线程的创建与销毁的守则
- 线程的创建比销毁要容易得多:
- 程序不应该在未提前告知的情况下创建自己的"背景线程";
- 尽量用相同的方式创建线程, 例如:muduo::thread;
- 在进入main()函数之前不应该启动线程;
- 程序中线程的创建最好能在初始化阶段全部完成;
- 线程是稀缺资源:
- 一个进程可以创建的并发线程数目受限于地址空间的大小和内核参数;
- 一台机器可以同时并行运行的线程数目受限与CPU的数目;
- 一旦程序中有不止一个线程, 就很难安全地fork()了;
- 因此库不能偷偷地创建线程;
- 如果确实要使用背景线程, 至少应该让使用者知道;
- 如果有可能, 可以让使用者在初始化库的时候传入线程池或event loop对象, 这样的程序就可以统筹线程数目和用途, 避免低优先级的任务独占某个线程;
- 如果库提供异步回调, 一定要明确说明会在那个(那些)线程调用用户提供的回调函数, 这样的用户就可以知道在回调函数中能不能执行耗时的操作, 会不会阻塞其他任务的执行;
- 在main函数之前不应该启动线程, 因为这会影响全局对象的安全构造;
- 各个编译单元之间的对象构造顺序是不确定的, 只能通过一些办法影响初始化顺序;
- 全局对象不能创建线程;
- 如果一个库需要创建线程, 那么应该进入main()函数之后再调用库的初始化函数去做;
- 不要为每个计算任务, 每次请求去创建线程, 一般也不会为每个网络连接创建线程, 那么应该进入main()函数之后再调用库的初始化函数去做;
- 一个服务器程序的线程数目应该与当前负载无关, 而应该与机器的CPU数相近, 即load average有比较小(最好不大于CPU数目)的上限;
- 如果有实时性方面的要求, 线程数目不应该超过CPU数目, 这样可以基本保证新任务总能及时得到执行, 因为总有CPU是空闲的;
- 最好在函数程序的初始化阶段创建全部工作线程, 在程序运行期间不再创建或销毁线程;
- 线程的销毁有几种方式:
- 自然死亡 -- 从线程主函数返回, 线程正常退出;
- 非正常死亡 -- 从线程主函数抛出异常或线程触发segfualt信号等非法操作;
- 自杀 -- 在线程中调用pthread_exit()来立刻退出线程;
- 他杀 -- 其他线程调用pthread_cancel()来强制终止某个线程;
- pthread_kill()是往线程发信号;
- 强行终止线程的话(无论是自杀还是他杀), 它没有机会清理资源;
- 杀死一个进程比杀死本进程内的线程要安全得多;
- fork()的新进程与本进程的通信方式也要慎重选择, 最好用文件描述符(pipe/socketpair/TCP socket)来收发数据, 而不要用共享内存和跨进程的互斥器等IPC, 因为这样有死锁的可能;
- 如果thread对象的生命期短于线程, 那么析构时会自动detach线程, 避免了资源泄漏;
- 如果做到了程序中线程的创建最好能在初始化阶段全部完成, 则线程是不必销毁的, 伴随进程一直运行, 彻底避开了线程安全退出可能面临的各种困难, 包括thread对象生命期管理, 资源释放等等;
pthread_cancel与C++
- POSIX threads有cancellation point这个概念, 意思是线程执行到这里有可能会被终止(cancel)(如果别的线程对它调用了pthread_cancel()的话);
- 在C++中, cancellation point的实现与C语言有所不同, 线程不是执行到此函数为止, 而是该函数会抛出异常;
- 这样可以有机会执行stack unwind, 析构栈上对象(特别是释放持有的锁);
exit在C++中不是线程安全的
- exit函数在C++中的作用除了终止进程, 还会析构全局对象和已经构造完的函数静态对象;
- 用全局对象实现无状态策略在多线程中析构可能是很危险的;
- C++标准没有照顾全局对象在多线程环境下的析构, 也没有更好的办法;
- _exit()系统调用不会析构全局对象, 但是也不会执行其他所谓的清理工作, 比如flush标准输出;
- 安全地退出一个多线程序并不是一件容易的事情;
- 这需要精心设计共享对象的析构顺序,防止各个线程在退出时访问已失效的对象;
- 在编写长期运行的多线程服务程序的时候, 可以不必追求安全地退出, 而是让进程进入拒绝服务状态, 然后就可以直接杀死掉;
善用__thread关键字
- __thread是GCC内置的线程局部存储设施(thread local storage);
- 它的实现非常高效, 比pthread_t快得多;
- __thread变量的存取效率可与全局变量相比;
- __thread使用规则:
- 只能用于修饰POD类型(plain old data), 不能修饰class类型, 因为无法自动调用构造函数和析构函数;
- __thread可以用于修饰全局变量、函数内的静态变量, 但是不能修饰函数的局部变量或者是class的普通成员变量;
- __thread变量的初始化只能用编译期常量;
- __thread变量是每个线程有一份独立实体, 各个线程的变量值互不干扰;
- __thread变量还可以修饰值可能会改变, 带有全局性, 但是又不值得用全局锁保护的变量;
多线程与IO
- 同步IO: 阻塞和非阻塞;
- 操作文件描述符的系统调用本身是线程安全的, 我们也不用担心多个线程同时操作文件描述符会造成进程崩溃或内核崩溃;
- socket读写的特点是不保证完整性, 读100字节有可能只返回20字节, 写操作也是一样的;
- 多线程read或write同一个文件也不会提速, 多线程分别read或write同一个磁盘上的多个文件也不见得能提速;
- 因为磁盘都有一个操作队列, 多个线程的读写请求到了内核是排队执行的;
- 只有内核缓存了大部分数据的情况下, 多线程读这些热数据才可能比单线程快;
- 一个线程可以操作多个文件描述符, 但一个线程不能操作别的线程拥有的文件描述符;
- 当一个线程正阻塞在epoll_wait()上时, 另一个线程修改此epoll fd的事件关注列表(watch list)会发生什么;
- 为了稳妥起见, 我们应该把对同一个epoll fd的操作(添加, 删除, 修改, 等待)都放到同一个线程中执行;
- 一般程序不会直接使用epoll, read, write, 这些底层操作都由网络库代劳了;
- 对于磁盘文件, 在必要的时候多个线程可以同时调用pread/pwrite来读写同一个文件;
- 对于UDP, 由于协议本身保证消息的原子性, 在适当的条件下(比如消息之间彼此独立)可以多个线程同时读写同一个UDP文件描述符;
用RAII包装文件描述符
- Linux的文件描述符(file descriptor)是小整数, 在程序刚刚启动的时候, 0是标准输入, 1是标准输出, 2是标准错误;
- POSIX标准要求每次新打开文件(含socket)的时候必须使用当前最小可用文件描述符号码;
- POSIX这种分配文件描述符的方式稍不注意就会造成串话;
- 用socket对象包装文件描述符, 所有对此文件描述符的读写操作都通过此对象进行, 在UI想的析构函数里关闭文件描述符;
- 现代C++的一个特点是对象生命期管理的进步, 体现在不需要手工delete对象;
- muduo库使用shared_ptr来管理tcpConnection的生命期, 这是唯一一个采用引用计数方式管理生命期的对象;
RAII与fork()
- 我们用对象来包装资源, 把资源管理与对象生命期管理统一起来(RAII);
- 但是如果程序会fork(), 这一假设就会被破坏了;
- fork后, 子进程会继承父进程的几乎全部状态, 但也有少数例外;
- 子进程会继承虚拟地址空间和文件描述符;
- 子进程不会继承:
- 父进程的内存锁, mlock, mlockall;
- 父进程的文件锁, fcntl;
- 父进程的某些定时器, settime, alarm, timer_create等;
- 因此在编写服务器程序的时候, 是否允许fork()是在一开始就应该慎重考虑的问题;
多线程与fork()
- 多线程和fork()的协作性很差;
- fork()一般不能在多线程中调用, 因为Linux的fork()只克隆当前线程的thread of control, 不克隆其他线程;
- fork()之后, 除了当前线程之外, 其他线程都消失了;
- 也就是说不能一下子fork出一个和父进程一样的多线程子进程;
- fork()之后, 子系统不能调用:
- malloc, 因为malloc()在访问全局变量状态时几乎肯定会加锁;
- 任何pthreads函数, 不能用pthread_cond_signal()去通知父进程, 只能通过读写pipe来同步;
- printf()系列函数, 因为其他线程可能恰好持有stdout/stderr的锁;
- 除了man 7 signal中明确列出的signal安全函数之外的任何函数;
- 照此一来, 唯一安全的做法是在fork()之后立即调用exec()执行另一个程序, 彻底隔断子进程与父进程的联系;
多线程与signal
- Linux/Unix的信号(signal)与多线程可谓是水火不容;
- 多线程时代, signal的语义更为复杂, 信号分为两类: 发送给某一线程(SIGSEGV), 发送给进程中的任一线程(SIGTERM), 还要考虑掩码(mask)对信号的屏蔽等;
- 特别是在signal handler中不能调用任何pthreads函数, 不能通过condition variable来通知其他线程;
- 在多线程中, 使用signal的第一原则不要使用signal;
- 不要用signal作为IPC的手段, 包括不要用SIGUSR1等信号来触发服务端的行为;
- 也不要使用基于signal实现的定时器函数, 包括alarm/ualarm/settime/time_create, sleep/usleep等;
- 不主动处理各种异常(SIGTERM, SIGINT), SIGPIPE服务器程序通常的做法是忽略这个信号;
- 在没有办法的情况下, 把异步信号转换为同步的文件描述符事件;
- 现代Linux采用signalfd把信号直接转换为文件描述符事件, 从而根本不用使用signal handler;
Linux新增系统调用的启示
- signalfd, timerfd, eventfd;
- 凡是会创建文件描述符的syscall一般都增加了额外的flags参数, 可以直接指定O_NONBLOCK(非阻塞)和FD_CLOEXEC(程序exec时进程自动关闭这个文件描述符);
- 为了回避fork()+exec()之间文件描述符泄漏的race condition;
posted @
2018-10-11 22:56
coding-for-self
阅读(
458)
评论()
编辑
收藏
举报