Linux多线程服务端编程笔记(四)
第四章 C++多线程系统编程精要
1、多线程编程最大的思维方式的转变有两点
- 当前线程可能随时会被切换出去,被抢占
- 多线程程序中事件的发生顺序不再是全局统一的先后关系
2、 多线程程序的正确性
- 不能依赖任何一个线程的执行速度
- 不能通过原地等待(sleep)来假定其他线程事件已经发生,必须通过适当的同步来让当前事件能够看到其他线程执行的结果
- 无论线程执行的快慢,程序都能正常工作
3、 基本线程原语的选用
- 2个:线程的创建和等待结束(jion),封装成muduo::Thread;
- 4个:mutex的创建、销毁、加锁、解锁
- 5个:条件变量的创建、销毁、等待、通知、广播
4、 C/C++系统库的线程安全性
- 不用担心系统调用的线程安全性、因为系统调用对于用户态程序来说是原子的。但是需要注意它的使用对内核状态的改变可能会影响其他线程
- C++标准容器库和std::string 不是线程安全的,只有std::allocator是线程安全的
- C++库中的绝大多数泛型算法是线程安全的
- C++的iostream不是线程安全的
5、Linux上的线程标识
-
pthread_self
函数用于返回当前线程的标致符,类型为pthread_t
注意pthread_t:
- 无法打印输出,不知道确切类型,因此日志中无法用它表示当前线程
- 无法比较大小或计算其hash值
- 无法定义一个非法的pthread_t值来表示不可能存在的线程id
- 只在进程中有意义,与操作系统的任务调度之间无法建立有效管理
-
pthread_equal
对比两个线程标志符是否相等 -
Linux系统上建议gettid(2)系统调用的返回值来作为线程的id,原因如下
-
类型是pid_t,是一个小整数, 便于在日志中输出
-
它直接表示任务调度id,在/proc文件系统中可以轻易找到对应项
-
其他系统工具也容易定位到具体某一个线程,top(1)
-
任何时刻都是全局唯一的,分配新的pid是递增轮回办法
-
0是非法的,系统第一个进程init的pid是1;
-
4、线程创建与销毁的守则
线程的创建原则:
- 程序库不应该在为提前告知的情况下创建自己的“背景线程”
- 尽量用相同的方式创建线程
- 进入main函数之前不应该启动线程
- 程序中线程的创建最好在初始化阶段全部完成
线程的销毁方式
-
自然死亡。从线程的主函数返回,线程正常退出
-
非正常死亡,抛出异常或触发致命信号
-
自杀。 自己调用pthread_exit()退出
-
他杀。其他线程调用pthread_cancle()
注意,线程正常退出的方式只有一个,自然死亡,任何从外部强行终止线程的做法和想法都是错误的
6、exit(3)在C++中不是线程安全的
exit(3)函数在C++中的作用除了终止线程,还会析构全局对象和以及构造完成的函数静态对象。这有存在死锁的可能
7、善用__thread关键字
- __thread是GCC内置的线程局部存储设施,他的实现非常高效,存取速率可以与全局变量相比
- 使用规则:智能修饰POD(普通旧类型)类型,不能修饰class类型,因为无法调用构造函数与析构函数
- __thread变量是每个线程有的一份实体,各个线程的变量互不干扰。除此之外,还可以修饰那些值可能会改变,带有全局性,但是又不值的用全局锁保护的变量
8、多线程与IO
- 每个文件描述符只由一个线程操作,从而轻松解决消息收发的顺序性问题,也避免了关闭文件描述符的各种 race condition。一个线程可以操作多个文件描述符,但一个线程不能操作别的线程拥有的文件描述符。
- epoll也遵循相同的原则,把同一个epoll的操作(添加、删除、修改、等待)都放到同一个线程中执行
- 一般的程序不会直接使用epoll、read、write,这些底层操作由网络库代劳
9、用RAII包装文件描述符
-
linux的文件描述符是小整数,程序刚启动时候,0是标准输入,1是标准输出,2是标准错误。因此新打开的文件描述符是3
-
POSIX标准要求每次打开文件的时候必须是当前最小可以文件描述符符号,因此这种方式会造成串话
比如:
- 第一个线程read某个socket,第二个线程几乎同时close此socket,第三个线程又打开了另一个文件描述符,与之前的相同,则会导致第一个线程会读不到属于他的数据
- fd = 8收到了比较耗时的请,开始处理这个请求,并记住将响应结果给fd = 8,.但是在处理过程中fd = 8 断开连接,于是在处理完成之后,返回响应已经物是人非了
解决这个问题的办法
- 单线程程序,使用某种全局表
- d多线程程序:RAII,用Socket对象包装文件描述符,只要socket还活着,就不会有其他的Socket对象有相同的文件描述符
10、 RAII与fork()
-
fork()之后,子进程会继承父进程的几乎所有的状态,但也有少数例外
-
子进程会继承地址空间和文件描述符,因此用于管理动态内存和文件描述符的RAII class 都能正常工作,但是子进程不能继承:
- 父进程的内存锁
- 父进程的文件锁
- 父进程的某些定时器
- 其他
由此得出结论,RAII技法与fork冲突
11、多线程与fork()
- fork一般不能在多线程程序中调用,因为fork只克隆当前线程的thread of control。不克隆其他线程。因此fork之后,除了当前线程,其他线程就消失了
- 唯一安全的做法,fork之后立即调用exec()执行另一个程序,彻底隔断子进程与父进程的联系
- windows下的创建创建进程与当前进程关联要比较少
12、多线程与signal
- linux信号与多线程水火不容
- signal会打断正在运行的线程控制(thread of control)
13、本章总结
多线程C++编写原则
- 线程时宝贵的,一个程序可以使用几个或几十个线程,但一台机器不应该同时运行几百个,几千个线程,会增加内核的负担
- 线程的创建和销毁是有代价的,最好一开始就创建所有线程反复使用,如果必须那么做,最好是一分钟一次
- 每个线程都应该有明确的职责,例如IO线程或计算线程
- 线程之间的交互应该尽量简单,理想情况下只用消息传递,如果必须使用锁,最好避免同一个线程拥有两把锁
- 考虑清楚一个mutable shared 对象会暴露给哪些线程,每个线程是读还是写,是否并发