【Linux多线程服务端编程】| 【04】C++多线程系统编程精要

发布于:2022-12-06 ⋅ 阅读:(642) ⋅ 点赞:(0)

【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;
    • 若多个线程同时,那数据该如何拼合?;

【那么只有读写能分到两个线程吗?】

  • 要避免lseekread的竞态条件;
  • 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_NONBLOCKFD_CLOEXEC

  • accept、eventfd、inotify、pipe、signalfd、timerfd_create;

【O_NONBLOCK】:开启非阻塞,文件描述符默认为阻塞;
【FD_CLOEXEC】:让exec时,进程自动关闭这个文件描述符;

12 多线程原则

  • 线程宝贵,不应该开太多,几个到十几个即可;
  • 线程创建、销毁有代价,可反复使用,不要反复创建销毁,若这样,则频度降低到1分钟1次
  • 每个线程都要有目前的职责
  • 线程交互要简单,考虑到
  • 要预先考虑mutable shared对象将会暴露给哪些线程,每个线程时读还是写;

网站公告

今日签到

点亮在社区的每一天
去签到