文章目录
【Linux多线程服务端编程】| 【01】线程安全的对象生命期管理笔记
【Linux多线程服务端编程】| 【02】线程同步精要
【Linux多线程服务端编程】| 【03】多线程服务器的适用场合和常用编程模型
【Linux多线程服务端编程】| 【04】C++多线程系统编程精要
【思维转变】
- 当前线程
切出
去,或被抢占
; - 多线程中事件的顺序不再有全局统一的先后;
在无锁
的情况下,全局数据可能被其他线程修改,导致后续可能出现致命
错误,需要通过适当的同步
来让当前线程可看到其他线程的事件的结果;
1 基本线程原语的选用
【11个基本的Pthreads函数】
- 2个:线程的
创建
和等待
结束; - 4个:mutex的
创建
、销毁
、加锁
、解锁
; - 5个:条件变量的
创建
、销毁
、等待
、通知
、广播
;
【不推荐,但可使用】:
- pthread_once:可用全局代替;
- pthread_key:可用__thread;
- pthread_rwlock:慎用;
- sem_*:易出错;
- pthread_kill,cancel:使用到该函数,则设计有问题;
2 C/C++系统库的线程安全性
【内存序/内存能见度】
- 规定一个线程对某个共享变量的修改何时能被其他线程
看到
;
【errno】
- Linux glibc将
errno
定义为一个宏,为左值
,必须定义为对函数返回指针的dereference
;
【glibc】
- 大部分为线程安全,特别是
FILE*系列
;当然也提供非线程安全版本,应对某些特殊场合的性能需求; 单个
函数是线程安全,但多个一起
可能就不安全,如seek和read,可使用flockfile(FILE*)和funlockfile(FILE*)加锁;且FILE*锁是可重入
的;
【把类设计成线程安全】
- 尽量将类设计成
immutable
的; - 凡
非共享
的对象都是彼此独立
的,从始至终只被一个线程用到; - 共享对象的
read-only
是安全的,不能有并发写
;
容器线程安全
- 标准库
容器
和std::string
不是线程安全,只有std::allocator
是;- 主要为了避免不必要的开销,且单个成员函数的线程安全不具备
可组合
性;
- 主要为了避免不必要的开销,且单个成员函数的线程安全不具备
- 大多数
泛型
算法是线程安全的,由于它们是无状态虚函数
; iostream
不是线程安全;
3 Linux上的线程标识
【标识】:POSIX threads提供了pthread_self
用于返回当前进程的标识符
,类型为pthread_t
(整数/指针/结构体);
- 且提供
pthread_equal
用来对比两个线程标识符是否相等; - 在glibc中pthread_t指向一个
结构体指针
,反复
使用,可能造成重复id;
【pthread_t存在线程安全】
- 无法打印输出pthread_t,
类型不明确
; - 无法
比较
pthread_t大小或计算hash值; - 无法定义一个
非法
pthread_t值;
【替代使用gettid】:获取线程id
- 类型为pid_t,
易输出
; - 直接表示内核的任务调度id,
/proc/tid或/prod/pid/task/tid
; 易定位
到具体某一线程;- 任何时刻都是
全局唯一
,且由Linux分配新pid采用递增轮回,即使短时间内多个线程,也会有不同id; 0
为非法值;
【优化gettid为系统调用,如何高效使用】
- 采用
__thread
变量来缓存gettid
返回值,只有本线程第一次调用才进行系统调用,后续直接从本地缓存
中获取;
4 线程的创建与销毁的守则
4.1 创建
- 程序库不应再
未提前告知
的情况下创建自己的背景线程
; - 尽量用
相同
的方式创建线程; - 进入
main函数前
不应该启动线程,会影响全局对象
的安全构造,全局对象也不能创建线程; - 线程创建最好再
初始化
阶段全部完成;
【进程限制】:地址空间
的大小和内核
参数;
【线程限制】:CPU的数目;
【注意事项】
- 若使用
背景线程
,则需要让使用者知道; - 让使用者再初始化库的时候传入
线程池
或event loop
对象,便于统筹线程数目、用途,避免优先级的任务独占某个线程; - 不要为每个
计算
任务,每次请求
去创建线程;
4.2 销毁
自然死亡
,从线程主函数返回,线程正常退出;非正常死亡
,从线程主函数抛出异常或线程触发segfault信号等非法操作;自杀
,在线程中调用pthread_exit()来立刻退出线程;他杀
,其他线程调用pthread_cancel来强制终止某个线程;- 强制终止,将会导致没有机会
清理
资源,没有机会释放已持有的锁
,可能导致其他线程死锁;
【如何安全杀死一个线程】
- 考虑将
fork
新的进程,这样kill一个进程比杀死本进程内的线程要安全得多; - 当然fork也需要注意,通信方式最好选用
pipe/socketpair/socket
,而不是mmap
和跨进程
互斥器,仍导致死锁
;
【线程RAII class】
- 一般会让Thread对象的生命周期长于线程,通过
join
来等待线程结束并释放线程资源;
【exit在C++不是线程安全】:
- 除了终止进程,还
析构
全局对象和已经构造完的函数静态对象,可能导致死锁
; - 若需要主动结束,则考虑使用
_exit
(2),该函数不会析构全局对象,且不会执行清理工作; - 而服务器程序可以不必安全退出,让进程进入拒绝服务状态,直接
kill
;
【例1】:
【例2】:调用纯虚函数导致程序崩溃;
5 善用__thread关键字
- 是GCC内置的线程局部存储,相比于
pthread_key_t
快; - 每个线程
独立
实体,互不干扰;
【__thread变量的存取效率可与全局变量相比】
- 只能修饰
POD
类型,不能修饰class
类型,由于无法自动调用构造、析构
; - 可以修饰
全局
变量、函数内的静态
变量,不能修饰局部
变量或普通成员
变量; - 该变量初始化只能用编译器常量;
【应用好处】:
- 缓存最近一条日志时间,若在一秒内输出多条日志,可避免重复格式化;
- 缓存线程id,协程id;
6 多线程与IO
同步IO:阻塞与非阻塞;
【是否能使用多个线程处理一个socket】
- 首先,不用文件描述符是线程安全,不同担心多线程应用在上面会奔溃;
- 但该做法可能得不偿失:
- 若一个线程正在阻塞read某个socket,而另一个正在close,此时又打开一个描述符,可能出现
串号
; - 若一个线程正在阻塞accept,而另一个close;
- 若多个线程同时
读
,那数据该如何拼合
?;
- 若一个线程正在阻塞read某个socket,而另一个正在close,此时又打开一个描述符,可能出现
【那么只有读写能分到两个线程吗?】
- 要避免
lseek
和read
的竞态条件; - read、write分两个线程见不得提速,由于每块磁盘都有一个操作队列,多线程读写请求到
内核
时排队执行; - 一个文件只由一个线程来读写;
【多线程磁盘IO】 :每个磁盘配一个线程,把所有对此磁盘IO都挪到同一个线程;
【特例】
- 对于磁盘文件,必要时多个线程可同时调用
pread/pwrite
读写同一个文件; - 对UDP,由于协议本身保证
原子性
,适当条件可多线程同时读写一个UDP文件描述符;
7 用RAII包装文件描述符
- 0是标准输入、1是标准输出、2是标准错误;若打开文件则按当前
最小可用
号码; - 用
Socket
对象包装文件描述符,读写都通过该对象
操作; - 当
析构
时,即关闭
文件描述符,若只要对象还存活,即不会出现一样的文件描述符;
为什么不关闭标准输出1、2
- 由于某些
第三库
可能会往stdout或stderr输出信息; - 若关闭,可能被网络连接
占用
,导致对方接收到奇怪的数据; - 正确,应该把stdout或stderr重定向到
磁盘
中,方便查看;
RAII应用于网络编程中
长命
的对象(TcpSever),生命周期如程序一样长,易处理
,可直接使用全局
对象/或main中栈
对象;短命
的对象(TcpConnection),生命周期不由我们控制(客户断开),则该对象为堆对象,不能直接delete,可能在别处引用,需要确保引用计数为1
,在delete;
非阻塞网络编程场景:从某个TCP连接A收到req,程序处理它需要一定时间,为了避免阻塞,程序需记住发来的req,在某个线程池中处理;完毕后,会将rsp发送回A;但处理req时,客户端断开连接,而另一个客户端连接B;
- 对此,我们不能只记住A的文件描述符,应持有封装
socket
连接的TcpConnectionm对象,保证该文件描述符不会被关闭;这样即可区分是否为新连接还是旧的;
8 RAII与fork
【例】:在fork中可能会出现一个构造
,多次析构
;
- 子进程继承了地址空间、文件描述符,父进程所有状态;
- 子进程不继承,内存锁、文件锁、定时器等;
- 故在编程时,需要慎重考虑是否可用fork;
9 多线程与fork
- fork一般不在多线程中调用,Linux的fork只
克隆当前线程
; - fork后只有一个线程
其他
线程都消失
;若其他线程正在临界区
内,死亡后则不会被解锁
,此时子进程试图对同一个mutex加锁,则会立刻死锁
; - fork后子进程相当于
signal handler
中,不能调用线程安全的函数,只能调用异步
信号安全的函数,或可重入
; - 【fork后不能调用】:
malloc
该函数在访问全局状态时会加锁;- 任何可能
分配
会释放
内存的函数; - 任何Pthreads函数,只能用
pipe
,不能用pthread_cond_signal通知父进程; printf
系列;
10 多线程与signal
在单线程中处理信号函数,在signal handler
只能调用可重入
函数;若signal handler需要修改全局
函数,则修改的变量必须为sig_atomic_t类型,从而优化了内存访问;
在多线程中,使用signal第一原则不要使用signal:
- 不要使用signal作为
IPC
通信; - 不要使用基于signal实现的
定时
函数; - 不主动处理各种
异常信号
,只用默认,但例外使用SIGPIPE
避免对方断开,本机继续write; - 在无其他方法替代,则将异步信号转为同步文件描述符时间;即往
pipe写一字节
;
11 Linux新增相同调用
从内核2.6开始,创建文件描述符的syscall一般增加flags参数可指定O_NONBLOCK
和FD_CLOEXEC
:
- accept、eventfd、inotify、pipe、signalfd、timerfd_create;
【O_NONBLOCK】:开启非阻塞,文件描述符默认为阻塞;
【FD_CLOEXEC】:让exec时,进程自动关闭这个文件描述符;
12 多线程原则
- 线程宝贵,不应该开太多,
几个到十几个
即可; - 线程创建、
销毁有代价
,可反复
使用,不要反复创建销毁,若这样,则频度降低到1分钟1次
; - 每个线程都要有目前的
职责
; - 线程
交互
要简单,考虑到锁
; - 要预先考虑
mutable shared
对象将会暴露给哪些线程,每个线程时读还是写;