线程安全的对象生命周期管理

析构函数&多线程

多线程共享对象,对象的线程安全特性可以由对象内的同步原语保护。但是对象的析构没法通过内部的同步原语保护。(猜测要通过外部的同步原语保护)

比如如何保证在执行成员函数期间,对象不会被其他线程析构。

线程安全

线程安全的类满足如下三个条件:

  • 多线程可以同时访问,表现正常。
  • 无论这些线程的执行顺序如何交织。
  • 调用端无需同步操作。

C++大部分类,string,vector都不是线程安全的,也可以理解,毕竟需要线程安全的时间也不多,没必要增加额外的开销。

对象构造&线程安全

如何保证对象构造的线程安全:在构造期间不要泄露this指针。

可以使用两段式构造。

对象销毁&线程安全

很难解决的问题。文章从面对对象程序设计的角度提出了一些问题。

对象的关系包括三种:

  • composition(组合):如struct A{struct B b;};b的生命周期由A决定,A对象被析构时,b也被析构。
  • association(关联):在形式上与下面一条很类似,生命周期独立。
  • aggregation(聚合):如struct A{struct B* b};b,生命周期独立。如果b被释放,则会面临竞态条件。

shared_ptr,循环引用导致两者都没办法被释放。

空悬指针:指向已经被释放内存的指针,比如a,b同时指向一段内存,但是a释放了该段内存。

shared_ptr

传递原始指针简直就是一个灾难,前者没法判断后者对指针的操作,后者也没办法判断前者对指针的操作。

shared_ptr是强引用,会增加对象的引用,而weak_ptr不会,他只想想知道对象是否存在。

使用智能指针通常可以解决如下问题:

  • 缓冲区溢出:可以自动记录缓冲区的长度,通过成员函数访问
  • 空悬指针:用智能指针就不空悬了
  • 重复释放/内存泄漏
  • 不配对的new/delete

这两个智能指针都是值语意,不会有如下写法:

1
auto ptr = new shared_ptr<Foo>(new Foo);//谁能干出这种事啊?

shared_ptr的线程安全

shared_ptr的内部数据包含计数的地址和内存区域的地址,包含两个值,多线程同时读写一个shared_ptr对象时,可能导致错误。——即shared_ptr对象本身不是线程安全的。

对shared_ptr指向的内存区域也不一定是内存安全的,需要程序员包含。

使用shared_ptr时,只能保证对引用计数的更新是线程安全的。

shared_ptr的陷阱

  1. 使用shared_ptr会导致引用计数无法归零。

书上的一个例子,observer在observable中注册了一个对自己(observer)的shared_ptr, 当observer被析构时,当然也希望observable中的shared_ptr被删除。

一种办法是在在observer中的析构函数中调用observerable的取消注册函数,这本身就是一个悖论,因为只有在引用计数为0才会执行析构函数,而observable中保留着引用,那么引用用于不会归零,也就导致永远都不会调用析构函数。

  1. 可以使用const &来传递shared_ptr, 来减小开销。

  2. RAII handle。为了避免循环引用,对象的所有者应该使用shared_ptr, 而一些不太需要对象的需要使用weak_ptr。

对象池

线程同步

四项原则:

  • 尽量少共享对象,如果要暴露,先考虑immutable对象,其次才是可以修改的对象,但需要充分的同步措施来保护他。
  • 使用高级的并发编程构件:TaskQueue、Producer-Consumer Qeueu。
  • 最后不得已才使用底层同步原语,只使用非递归的互斥器和条件变量,慎用读写锁,不用信号量
  • 除了使用原子操作,不要使用无锁代码, 不要使用内核级同步原语。

仔细想想,还是1和2方面讷

互斥器

互斥器保护了临界区,基本原则:

  • 用RAII封装mutex。
  • 只用不可重入的mutex。
  • 使用guard。
  • 每次构造guard对象是,思考一路上已经持有的锁,防止加锁顺序不同而死锁。看调用栈就可以分析用锁的情况。

次要原则包括:

  • 不使用跨进程的mutex,进程间通信只使用sockets。
  • 必要的时候使用PTHREAD_MUTEX_ERRORCHECK来排错。
只使用非递归的mutex

使用非递归锁可以把程序的逻辑错误暴露出来。

gdb 使用thread apply all bt打印所有栈的信息

死锁

条件变量(管程)

  1. 必须和mutex一起使用
  2. 在mutex上锁的时候才能调用wait
  3. 把布尔条件和wait放到while循环中

条件变量是非常底层的同步原语,很少直接使用,一般使用它实现的高层的同步措施。如BlockingQueue或CountDownLatch。

不要用读写锁和信号量

封装MutexLock、MutexLockGuard、Condition

利用shared_ptr实现copy-on-write

利用shared_ptr替代读写锁,shared_ptr本身包含计数,可以代替信号量的作用,shared_ptr引用为1时,写进程可以进行写,如果引用大于1时,此时其他使用该引用的进程。

多线程服务器

单线程服务器的编程模型

常用的模式:non-blocking IO + IO multiplexing模式,即Reactor模式。

程序的结构:事件循环,以事件驱动+事件回调的方式实现业务逻辑。

缺点:回调函数应该是非阻塞的。

多线程服务器编程模型

one loop per thread

进程间通信

首选TCP,主要是可以跨主机,改改host:port就能直接用。

多线程服务器的适用场合

必须使用(多进程)单线程的场合
  • 程序可能fork的时候,(多线程程序不应该fork,因为fork之后,只有执行fork的哪个线程占据新进程的资源,其他线程会消失,如果遇到锁,会产生大问题)
  • 限制程序的CPU占用率,单线程即使发生busy-wait,CPU利用率也只有12.5%,而如果是多线程,可能导致多个线程跑满核心。
适用多线程程序的场景

无论是IO bound任务还是CPU bound任务,多线程程序都没有什么性能优势。

  • IO bound,任务的执行速度瓶颈在于IO, IO跑满后,cpu仍然没有达到上限。改为多线程程序当然也不会提升性能。
  • CPU bound,任务的执行速度瓶颈在于CPU。改为多线程程序当然也不会提升性能。

使用多线程的场景:提高响应速度,让IO和计算相互重叠,降低latency。虽然多线程不能提升绝对性能,但能提高平均相对性能。(比如说单线程遇到IO就会陷入系统调用,CPU没有被利用,而多线程则可以利用CPU执行一些其他任务)大致要满足如下条件:

  • 多核
  • 线程间有能修改的共享数据,不能修改的共享数据可以用多进程单线程来实现。

看需不需要共享资源来选择多线程还是多进程,

比如处理IO任务,通常需要从listen线程先worker线程传送文件描述符,传送文件描述符这项操作只能在同进程内操作。所以这个任务适合使用多线程。

比如书上72页的例子,多个任务都需要根据机群的状态来决定处理的措施,这也适合用多线程。

线程的分类:

  • IO线程,线程主循环时IO multiplexing,阻塞的等在select/poll/epoll等系统调用上。
  • 计算线程,这类线程的主循环是blocking queue,阻塞的等在condition variable上,这类线程一般位于thread pool上,一般不涉及IO。
  • 第三方库的线程,比如logging,database connection。
问答

BlockingQueue:

通常工作线程需要往磁盘写log,这本身不属于线程的业务范围,为了提升线程的响应速度,可以将写任务通通交给一个日志线程去做。

线程没法减少工作量,通过调配,能让工期提早结束。

C++多线程系统编程

要注意,程序可能在任何时候中断,在任何时候启动。

基本线程原语的选用

posix函数中最有用的十几个:

  • 线程的创建和等待结束(join)。muduo::Thread
  • mutex的创建、销毁、加锁、解锁。muduo::MutexLock
  • 条件变量的创建、消耗、等待、通知、广播。muduo::Condition。

C/C++系统库的线程安全性

glibc库函数很多都是线程安全的,但是将多个库函数组合起来就不一定了。(比如先lseek在read,这之间可能被第三者打断)

C++标准库和string都不是线程安全的。

C++标准库大部分泛型算法是线程安全的,因为他们是无状态的。

iostream也不是线程安全的。因为

cout << "Now is" << time(NULL);等于如下两个函数调用

cout.operator<<("Now is").operator(time(NULL));这之间可能被其他线程打断。

可以改用printf实现线程安全的输出。

tips:

a();b();这两个函数一起执行不是线程安全的,不代表这两个函数都不是线程安全的。一个函数是不是线程安全的需要看其内部是不是对某个全局可写变量进行了非原子的多次操作。(个人看法)

Linux上的线程标识

  • 内核标识——tid,通过syscall(SYS_gettid)访问,用于操作系统内核进行线程调度和管理
  • POSIX标识,pthread_t, 在不同的平台代表的意义不一致:指针,结构体之类的。glibc的pthreads把pthread_t作为一个结构体指针,指向一块动态分配的内存,这块内存可能被反复使用,所以不能根据pthread_t的值来判断多个线程是否是同一个线程,需要通过pthread_equal来判断。

推荐使用gettid系统调用的返回值作为线程id。

线程的创建和销毁的规则

创建规则:

  • 应该精心规划线程数量,库不能偷偷的创建背景线程,这样也会导致没法安全的fork。
  • 用相同的方式创建线程。
  • 进入main之前不启动线程。如果不这样,创建的子线程可能访问到未初始化的全局变量。
  • 线程的创建最好能在初始化阶段全部完成。

线程的销毁形式:

  • 自然死亡——从线程主函数返回
  • 非自然死亡——主函数抛出异常或者触发segfault信号等。
  • 自杀——线程调用pthread_exit()
  • 他杀——其他线程调用pthread_cancel来终止某个线程

避免他杀:这会导致锁出现很多bug。

如果想要强行终止一个耗时很长的任务,可以考虑为该任务创建一个新的进程,这样kill该进程会安全很多,前提是不使用共享内存或者跨进程的互斥器等。

pthred_cancel

如果程序执行到cancellation point这个位置,其他线程调用了pthread_cancel时,该线程可能被终止,通常先会抛出异常,指向 stack unwind。

exit不是线程安全的

exit除了终止进程,还会析构全局对象和构造完的静态对象,,这可能产生死锁。

可以使用_exit()代替,他不会析构全局对象,也不会执行其他清理工作。

善用__thread关键词(C++11可以用thread_local替代)

__thread是gcc内置的线程局部存储设施,可以修饰全局、静态变量,但不能修饰class类型、局部变量等。__thread变量的初始化只能用编译期常量。__thread每一个线程都有一个独立实体

多线程&IO

对于epollfd的修改,可能已经有线程wait在epollfd上,这时某个线程对epollfd进行修改,

可以把对epollfd的操作都放到同一个线程执行。(epollfd如果被多个线程写、监听,可能导致不一致)

尽量让单个单个线程独占的读写一个文件描述符。(pread和pwrite可以从指定的偏移量读写, UDP也可以,因为消息是彼此独立的)

用RAII包装文件描述符

POSIX要求每次打开文件的时候必须使用最小可用的文件描述符号码。

避免多线程对单个文件描述符进行操作。比如A线程读fd6,B线程关闭fd6,C线程打开了新的fd6。这样A会读到错误的文件。

如何解决这个问题:使用RAII,用socket对象包装文件描述符,所有对文件描述符的操作都通过socket对象进行访问。(对象在则文件描述符在,对象不在则文件描述符不在)

服务端程序不应该关闭标准输出和标准错误,而是将stdout和stderr重定向到磁盘文件(最好不要是/dev/null)。

(客户端关闭连接时,TCPConnection对象不应该立刻被关闭,因为这会导致其中的socket对象被关闭。)

RAII和fork

通常使用对象保存资源,将资源管理和对象生命周期关联起来(RAII);

前面提到:如果一个多线程程序使用了锁,一个锁可能被各种线程持有,假如线程A调用fork,而此时线程B持有锁,那么fork后,其实只fork了调用fork函数的子线程——线程A的代码,子进程将不包含线程B,自然没有办法解锁,导致线程A也没法使用。

解决方法就是:由调用fork的线程在fork前持有所有锁,然后调用fork后,在父子进程中解开所有锁。(这可以防止锁被其他线程持有,导致解不开锁)。可以利用pthread_atfork来实现这个方法。

但是如果程序会fork,这一假设就被破坏了。对象可能构造单次,析构多次。

fork之后,子进程会继承地址空间和文件描述符,因此fork后,动态内存和文件描述符可以正常使用。但子进程不会继承:

  • 父进程的内存锁,mlock,mlockall
  • 父进程文件锁,fcntl
  • 父进程的某些定时器,setitimer,alarm,timer_create

在编写程序时,是否允许fork需要慎重考虑。

多线程和fork

多线程程序fork后,子进程就相当于处于signal handler中(类似于xv6中的中断一样,中断中可以使用spinlock,但绝对不可以使用sleeplock,但用户空间的锁肯定是sleeplock,所以所有锁都不应该使用),将没法调用线程安全的函数(因为其他多半需要加锁,但锁很有可能处于失效状态),只能调用异步信号安全的函数。比如不能调用:

  • malloc,访问全局状态时需要加锁
  • new、map::insert
  • 任何pthread函数
  • printf函数

write也可以先文件描述符写数据,但与printf不同的是,他是异步信号安全的。

多线程&signal

信号处理函数中,只能调用异步信号安全的函数。

signal分为两类:

  • 发送给某一线程,SIGSEGV
  • 发送给进程中的任一线程,SIGTERM

多线程程序中,使用signal的第一原则就是不要使用signal,包括

  • 不要使用signal作为IPC的手段,
  • 不要使用基于signal实现的定时函数,包括alarm/ualarm/setitimer/timer_create/sleep/usleep等。
  • 不主动处理各种异常信号(SIGTERM、SIGINT等),只用默认语义,结束进程。有一个例外,SIGPIPE,服务器程序通常需要忽略该信号。(因为我们可能往一个已经关闭的连接中写数据,如果不关闭,则可能导致程序终止)。
  • 如果没有其他的替代方法,比如需要处理SIGCHLD信号,那么可以把异步信号转换为同步的文件描述符事件。
    • 比如在signal handler中往一个特定的pipe写一个字节,在主程序中从pipe读取数据,然后处理。
    • 使用signalfd把信号转换为文件描述符事件,从根本上避免使用signal handler。

Linux新增系统调用的启示

从Linux内核2.6.27开始,凡是会创建文件描述符的syscall都增加了额外的flags参数,可以直接指定O_NONBLOCK和FD_CLOEXEC。

比如:accept4、eventfd2、inotify_init1、pipe2、signalfd4、timerfd_create。

以下新系统调用可以在创建文件描述符时开启FD_CLOEXEC选项:

dup、epoll_create、socket, 这个标志FD_CLOEXEC可以在程序执行exec时自动关闭这个文件描述符。

上面的八个函数都支持FD_CLOEXEC参数,就是为了避免fork+exec之间的文件描述符泄露。

tips:

  • 每个线程要有确定的职责,
  • 线程之间的交互尽量简单,使用消息队列传递消息,一个线程避免使用两把或更多的锁,

高效的多线程日志

日志有两个意思:

  • 诊断日志,即log4j、logback等日志库提供的日志功能
  • 交易日志,如数据库的write-ahead log,文件系统的journaling等,用于记录状态变更,保证事务的原子性

本文主要针对第一个日志。在生产环境中需要做到Log Everything All The Time。对于关键进程,日志通常需要记录:

  • 收到的每个内部消息的id(还可以包括关键字段、长度、hash)。
  • 收到的每条外部消息的全文。
  • 发出的每个消息的全文,每个消息都有全局唯一的id
  • 关键内部状态的变更。

日志库可分为前端(frontend)和后端(backend)两部分,前端是供用户程序使用的接口,并生成日志消息;后端负责将日志写到目的地。

如何将日志数据从多个前端传输到后端是一个典型的多生产者-但消费者问题。

  • 对生产者而言,要做到低延迟、低CPU开销、无阻塞
  • 对消费者而言,要做到足够大的吞吐量,并占用较少资源。

C++的日志库前端大致有两种API风格:

  • C/Java的printf(fmt, …)风格,例如log_info("Received %d bytes from %s." len, getClientName().c_str());
  • C++的stream<<风格,例如LOG_INFO << "Received" << len << "bytes from" << getClientName();

muduo日志库是C++ stream风格,这样用起来更自然,不用考虑保持格式字符串与参数类型的一致性。

功能需求

级别:在测试环境使用DEBUG级别的日志,生产环境使用INFO级别的日志。

对于分布式系统的进程而言,日志的目的地只有一个:本地文件。应该避免向网络文件系统写日志,避免故障扩散已经加速带宽消耗。

日志文件可以根据文件大小以及时间进行滚动。

格式:

logfile_test.20250411-191150.hostname.3605.log

  • logfile_test,是进程名字,通常是main函数argv[0]的basename。
  • 时间,可以使用通配符*.20250411-19*来选择一段时间的日志。
  • 机器的主机名
  • 进程id
  • 统一后缀

日志文件压缩和归档不是日志库应有的功能,应该交给专门的脚本去做,

日志存在一个问题,程序崩溃前,最后几条日志很可能丢失,解决方法:

  • 定期(3秒)将缓冲区的日志消息flush到硬盘
  • 每条内存中的日志消息都带有cokkie,其值为某个函数的地址,可以在core dump文件查找cokkie来找到尚未写入磁盘的消息。

日志的默认格式:

  • 每个日志占一行——便于使用awk、sed、grep等工具分析日志,比如grep -o "2025-03-24T14:22:.." .test.log | sort | uniq -c

  • 时间戳精确到微秒,每个消息都通过gettimeofday来获取当前时间,该函数不涉及系统调用。

  • 使用GMT时区,(GMT是本初子午线上的标准时间——唯一,UTC时间以附近的经线确定时间,每15度是一个不同的时区差一个小时,正午确定为12点。比如北京是UTC+8, 东京是UTC+9, 所以北京10点时,东京是11点)

性能需求

写的速度应该足够快,这样占用工作线程时间才足够短。

优化措施:

  • 一秒之内的多条日志只初始化微妙部分
  • 前四个字段定长,可以避免运行期求字符串长度。
  • 线程id预先格式化为字符串
  • 每行消息的源文件名使用编译器计算得来的basename。

多线程异步日志

异步日志:一个background负责收集日志,写入日志文件,其他业务线程负责往这个日志线程发送日志消息。

可以使用BlockingQueue,但不用每次产生一条消息都通知接收方。

muduo日志库使用双缓冲技术。这可以避免新建日志消息的时候等待磁盘文件操作。(如果是单缓冲区,向BlockingQueue写新消息时,如果日志线程正在往磁盘写数据,需要等待写完磁盘)前端将日志消息拼成一个大的buffer发给后端,避免了每次有消息就唤醒。即使buffer未满,日志库每3秒也会执行一次上述操作。

关键代码