04C++多线程系统编程精要

最后修改:

思维方式转变两点:

  • 当前线程随时可能会被切换出去,或者被抢占。
  • 多线程程序中事件的发生顺序不再有全局统一的先后关系。

基本线程原语的选用

Linux 平台 11 个最基本的 Pthreads 函数:

2个:线程的创建和等待结束(join)。封装为 muduo::Thread 4个:mutex 的创建、销毁、加锁、解锁。封装为 muduo::MutexLock 5个:条件变量的创建、销毁、等待、通知、广播。封装为 muduo::Condition

可以酌情使用的:

  • pthread_once. 封装为 muduo::Singleton 。其实不如直接用全局变量
  • pthread_key* ,封装为 muduo::ThreadLocal。可以考虑用 __thread 替换之。

不建议使用的:

  • pthread_rwlock, 读写锁通常应慎用。
  • sem_*, 避免使用信号量(semaphore)。它的功能与条件变量重合,但容易出错。
  • pthread_{cancel,kill}。程序出现他们,通常意味设计出了问题

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

对于标准而言,关键的不是定义线程库,而是规定内存模型(memory model)。特别是规定一个线程对某个共享变量的修改何时能被其他线程看到,这称为内存序(memory ordering)或者内存能见度(memory visibility)。

C/C++ 标准库中绝大多数泛型算法是线程安全的。

Linux 上的线程标识

pthread_t 不能作为线程标识,同一个进程先后不同的线程 pthread_t 可能相同

建议使用 gettid(2) 系统调用的返回值作为线程 id。好处:

  • 类型 pid_t, 通常是小整数,便于日志输出。最大值是 /proc/sys/kernel/pid_max 默认 32768
  • 直接标识内核的任务调度 id,因此 /proc 文件系统中可以找到对应项。 /proc/tid 或 /proc/pid/task/tid.
  • 使用其他工具 top 等也能查看
  • 任何时刻全局唯一。Linux 分配新 pid 采用递增轮回办法,短时间内启动的多个线程也会具有不同的线程 id
  • 0 是非法值,因此操作系统的第一个进程 init 的 pid 是 1

线程数目可以从 /proc/pid/status 拿到

gettid(2) 为系统调用,每次都系统调用比较浪费, muduo::CurrentThread::tid() 采用 __thread 变量缓存,只有在线程第一次调用的时候才会系统调用。以后直接从 thread local 缓存线程 id 拿结果(可参考 glib)。

fork(2) 子进程看到 stale 缓存,通过 pthread_atfork() 注册一个回调,情况缓存id

线程的创建与销毁守则

  • 程序库不应该在未提取告知的情况下创建自己的 “背景线程”
  • 尽量用相同的方式创建线程
  • 进入 main 函数之前不应该启动线程
  • 程序中线程的创建最好能在初始化阶段全部完成。

C++ 保证在进入 main() 函数之前完成全局对象(包括 namespace 级全局对象、文件级静态对象、class 的静态对象,但不包括函数内的静态对象)的构造

线程的销毁方式:

  • 自然死亡:从线程主函数返回,线程正常退出。
  • 非自然死亡:从线程主函数抛出异常或线程触发 segfault 信号等非法操作
  • 自杀:在线程中调用 pthread_exit() 来立刻退出线程
  • 他杀:其他线程调用 pthread_cancel() 来强制终止某个线程

pthread_cancel 与 C++

pthread_cancel 意思是线程执行到这里有可能被终止

在 C++ 中,线程不是执行到此函数立刻终止,而是该函数抛出异常,这样可以有机会执行 stack unwind,析构栈上对象(特别是释放持有的锁)

exie(3) 在 C++ 中不是线程安全的

exit(3) 函数在 C++ 中的作用除了终止进程,还会析构全局对象和已经构造完的函数静态对象,有潜在死锁可能。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include "../Mutex.h"

#include <stdio.h>
#include <stdlib.h>

using namespace muduo;

void someFunctionMayCallExit()
{
  exit(1);
}

class GlobalObject
{
 public:
  void doit()
  {
    MutexLockGuard lock(mutex_);
    someFunctionMayCallExit();
  }

  ~GlobalObject()
  {
    printf("GlobalObject:~GlobalObject\n");
    MutexLockGuard g(mutex_);
    // clean up
    printf("GlobalObject:~GlobalObject cleanning\n");
  }

 private:
  MutexLock mutex_;
};

GlobalObject g_obj;

int main()
{
  g_obj.doit();
}

如果必须退出,可以考虑 _exit(2) 系统调用,它不会试图析构全局对象,但是也不会执行其他任何清理工作。

善用 __thread 关键字

__thread 是 gcc 内置的线程局部存储设施。

__thread 只能用于修饰 POD 类型,不能修饰 class 类型,因为无法自动调用构造析构函数。

__thread 可以用于修饰全局变量、函数内的静态变量,但是不能用于修饰函数的局部变量或者 class 的普通成员变量。

__thread 变量的初始化只能用编译期常量。

__thread 变量是每个线程有一份独立实体,各个线程的变量值互不干扰。还可以修饰那些 “值可能会变,带有全局性,但是又不值得用全局锁保护” 的变量。

多线程与 IO

一个文件只由一个进程中的一个线程来读写,显然正确,多个线程操作同一块磁盘,在内核中也会排队。

多线程遵守的原则:每个文件描述符只由一个线程操作,从而轻松解决消息收发的顺序性问题,也避免了关闭文件描述符的各种race condition。一个线程可以操作多个文件描述符,但一个线程不能操作别的线程拥有的文件描述符。

对于磁盘文件,必要的时候多个线程可以同时调用 pread(2)/pwrite(2) 来读写同一个文件;对于 UDP,由于协议本身保证消息的原子性,在适当的条件下(比如消息之间彼此独立)可以多个线程同时读写同一个 UDP 文件描述符。

用 RAII 包装文件描述符

Linux 的文件描述符是小整数,程序刚刚启动的时候,0 是标准输入,1 是标准输出,2 是标准错误。这时新打开的文件描述符会是 3,因为 POSIX 标准要求每次打开文件(含 socket)的时候必须使用当前最小科研的文件描述符号码。

RAII,用 Socket 对象包装文件描述符,所有对此文件的描述符的读写操作都通过此对象进行,在对象的析构函数中关闭文件描述符。这样,只要 Socket 对象活着,就不会有其他 Socket 对象跟它一样的文件描述符。

为什么服务端程序不应该关闭标准输出和标准错误?因为第三方库在特殊紧急情况下会往 stdout 或 stderr 打印出错信息,如果我没程序关闭了标准输出和标准错误,这两个文件描述符有可能被网络连接占用,结果造成对方收到莫名奇妙的数据。正确做法是把 stdout 或 stderr 重定向到磁盘文件(最好不要 /dev/null),这样不至于丢失关键信息。当然这些应该由启动服务程序的看门狗进程完成(参考 http://github.com/chenshuo/muduo-protorpc 的 Zurg slave 示例)。对服务程序本身是透明的。

RAII 与 fork()

1
2
3
4
5
int main() {
    Foo foo;
    fork();
    foo.doit()
}

如果 Foo class 封装了某个资源,而这个资源没有被子进程继承,doit() 后子进程中错乱的。

fork() 后,子进程继承父进程的几乎全部状态,但是有些例外。子进程会继承地址空间和文件描述符,因此管理动态内存和文件描述符用 RAII 即可。但是子进程不会继承:

  • 父进程的内存锁,mlock(2)、mlockall(2)
  • 父进程的文件锁,fcntl(2)
  • 父进程的某些定时器, settimer(2)、alarm(2)、timer_create(2)…
  • 见 man 2 fork

多线程与 fork()

linux 的 fork() 只能克隆当前线程的 thread of control,不能克隆其他线程。 fork() 之后,除了当前线程外,其他线程都消失了。也就是说不能一下子 fork() 出一个和父进程一样的多线程子进程。

fork() 之后,子进程不能调用:

  • malloc(3)。因为 malloc() 在访问全局状态时几乎肯定会加锁。
  • 任何可能分配或释放内存的函数,包括 new、map::insert()、snprintf….
  • 任何 Pthreads 函数。 不能用 pthread_cond_singal() 去通知父进程,只能通过读些 pipe(2) 来同步(http://github.com/chensuo/muduo-protorpc 中 Zurg slave 示例的Process::start())
  • printf() 系列函数,因为其他线程可能恰好持有 stdout/stderr 的锁。
  • 除了 man 7 signal 中明确列出的 “signal 安全” 函数之外的任何函数

唯一安全的做法:fork() 之后立即调用 exec() 执行另一个程序。彻底隔断父子进程的联系。

多线程与 signal

多线程程序中,使用 signal 第一原则时不使用 signal。

  • 不要用 signal 作为 IPC 手段。包括不要用 SIGUSR1 等信号来触发服务端的行为。如果需要参考 9.5 增加监听端口方式来实现双向的、可远程访问的进程控制
  • 不要使用基于 signal 实现的定时函数,包括 alarm/ualarm/settimer/timer_create、sleep/usleep 。。。
  • 不主动处理各种异常信号(SIGTERM、SIGINT。。)只用默认语义结束进程。
  • 没有其他方法时,把异步信号改成同步的文件描述符。现代Linux 做法采用 signalfd(2) 把信号之间转换为文件描述符事件,从根本上避免使用 signal handler.(http://github.com/chenshuo/muduo-protorpc 中 Zurg slave 示例的 ChildManager class)

Reference

《Time,Clocks and the Ordering of Events in a Distributed System》 http://research.microsoft.com/en-us/um/people/lamport/pubs/time-clocks.pdf

《Threads Cannot be Implemented as a Library》 http://www.hpl.hp.com/techreports/2004/HPL-2004-209.pdf

http://pubs.opengroup.org/onlinepubs/9699919799/functions/V2_chap02.html#tag_15_09

自调整数据结构,只读也会不安全 http://www.cs.au.dk/~gerth/aa11/slides/selfadjusting.pdf

http://blog.csdn.net/program_think/article/details/3991107

http://www.cppblog.com/lymons/archive/2008/12/19/69810.html

http://www.cppblog.com/lymons/archive/2008/12/25/70227.html

http://www.boost.org/doc/libs/1_34_0/doc/html/thread/faq.html

http://stackoverflow.com/questions/433989/posix-cancellation-points

http://pubs.opengroup.org/onlinepubs/000095399/functions/xsh_chap02_09.html#tag_02_09_05_02

Cancellation and C++ Exceptions http://udrepper.livejournal.com/21541.html

《ELF Handing For Thread-Local Storage》 http://www.akkadia.org/drepper/tls.pdf

__thread 使用规则 http://gcc.gnu.org/onlinedocs/gcc/Thread_002dLocal.html

http://chenshuo.com/book/errata.html

http://www.linuxprogrammingblog.com/threads-and-fork-think-twice-before-using-them

http://www.cppblog.com/lymons/archive/2008/06/01/51836.html

http://www.linuxprogrammingblog.com/all-about-linux-signals?page=11

http://pubs.opengroup.org/onlinepubs/9699919799/functions/V2_chap02.html#tag_15_04_03

http://en.wikipedia.org/wiki/lnterrupt_handler

http://www.gnu.org/software/libc/manual/html_mono/libc.html#Atomic-Data-Access

http://www.cppblog.com/lymons/archive/2008/06/01/51838.html 和 51837.html

Linux 新增系统调用启示 http://blog.csdn.net/solstice/article/details/5327881

《Secure File Descriptor Handling》 http://udrepper.livejournal.com/20407.html

山重水复疑无路,柳暗花明又一村
使用 Hugo 构建
主题 StackJimmy 设计