Linux多线程服务端编程:第四章 C++多线程系统编程精要

学习多线程编程面临的最大的思维方式的转变有两点:

  • 当前线程可能随时会被切换出去,或者说被强占
  • 多线程程序中事件的发生顺序不再有全局统一的先后关系。
bool running = false;
void threadFunc() {
    // note 1
    while (running) {
        // get task from queue
    }
}
void start() {
    muduo::Thread t(threadFunc);
    t.start();
    running = true; // 这部分会出现问题
}

有人认为在note 1的位置前加一小段(sleep)就能解决问题,但这是错的,无论加多大的延时,系统都有可能先执行while的条件判断,然后再执行running的赋值。

4.1 基本线程原语的选用

不推荐使用读写锁的原因是它往往造成提高性能的错觉(允许多个线程并发读),实际上很多情况下,与使用简单的mutex相比,它实际上降低了性能。

多线程系统编程的难点不在于学习线程原语,而在于理解多线程与现有C/C++库函数和系统调用的交互关系。

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

C++的iostream不是线程安全的,因为流式输出:

std::cout << "Now is " << time(NULL);
// 等价于
std::cout.operator<<("Now is ")
         .operator<<(time(NULL));

4.3 Linux上的线程标识

pthread_equal函数用于比较两个线程标识符是否相等。这带来了一系列问题:

  • 无法打印输出pthread_t,因为其类型是不确定的
  • 无法比较pthread_t的大小或计算hash值,因此无法用作关联容器的key
  • 无法定义一个非法的phtread_t值,用来表示绝对不可能存在的线程id,因此mutex没办法判断当前线程是否已经持有本锁
  • pthread_t值只在进程内有意义,与操作系统的任务调度之间无法建立有效联系
  • phtread只保证统一进程之内,同一时刻的各个线程id不同;不能保证同一进程先后多个线程具有不同的id,更不用说一台机器上多个进城之间的线程id是唯一的了
int main()
{
    pthread_t t1, t2;
    pthread_create(&t1, NULL, threadFunc, NULL);
    printf("%lx\n", t1);
    pthread_join(t1, NULL);
    
    pthread_create(&t1, NULL, threadFunc, NULL);
    printf("%lx\n", t2);
    pthread_join(t2, NULL);
}

上面的运行结果,两个tid是相同的,因此。pthread_t不能确保全局唯一性。

不过,Linux上为我们提供了gettid()系统调用的返回值作为线程id。

  • 它的类型是pid_t,通常是一个小整数
  • 在现代Linux中,它直接表示内核的任务调度id,在/proc文件系统中可以轻易找到对应项:/proc/tid或/proc/pid/task/tid
  • 使用top命令可以定位到线程
  • 任何时刻都是全局唯一的,“Linux”分配的新pid采用递增轮回方法。短时间内启动的多个线程不可能会有相同的线程id
  • 0是非法值,因为操作系统的第一个进程init的pid是1

4.4 线程的创建于销毁的守则

线程的创建和销毁是编写多线程程序的基本要素,线程的创建比销毁要容易的多。我们需要遵守下面的线程创建原则:

  • 程序库不应该在未提前告知的情况下创建自己的“背景线程”
  • 尽量用相同的方式创建线程
  • 进入main()函数之前不应该启动线程(全局构造、各个编译单元之间的对象构造顺序是完全不同的)
  • 程序中线程的创建最好能在初始化阶段全部完成

线程的销毁有几种方式:

  • 自然死亡
  • 非自然死亡
  • 自杀(pthread_exit())
  • 他杀(其他线程调用pthread_cancel())

线程正常退出的方式只有一种:即自然死亡。任何从外部强行终止线程的想法和做法都是错的。因为强行终止线程的话,线程没有机会清理资源。也没有机会释放已经持有的锁,其他线程如果再想对同一个mutex加锁,那么势必产生死锁

4.4.1 pthread_cancel与C++

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

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

void someFunctionMayCallExit() {
    exit(1);
}
class GlobalObject {
    public:
        void doit() {
            MutexLockGuard lock(mutex_);
            someFunctionMayCallExit();
        }
        ~GlobalObject() {
            printf("GlobalObject: ~GlobalObject\n");
            MutexLockGuard lock(mutex_);    // 死锁
            printf("GlobalObject: ~GlobalObject cleanning\n");
        }
    private:
        MutexLock mutex_;
};
GlobalObject g_obj;

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

4.5 善用__thread关键字

__thread是GCC内置的线程局部存储设置(TLS)。“非常高效”

__thread使用规则:只能用于修饰POD类型,不能修饰class类型,==因为无法自动调用构造函数和析构函数。== 可以用于修饰全局变量、函数内的静态变量,但是不能用于修饰函数的局部变量或者class的不同成员变量。另外,__thread变量的初始化只能用编译器常量。

4.6 多线程与IO

多个线程同时操作同一个socket文件描述符确实很麻烦,是得不偿失的。需要考虑的情况如下:

  • 如果一个线程正在阻塞地read某个socket,而另一个线程close了此socket
  • 如果一个线程正在阻塞accept某个listening socket,而另一个线程close了此socket
  • 一个线程正准备read某个socket,而另一个线程close了此socket;第三个线程又恰好open了另一个文件描述符,其fd号码正好与前面的socket相同。这样程序的逻辑就混乱了

不考虑关闭文件描述符,只考虑读写:

  • 如果两个线程同时read同一个TCP socket,两个线程几乎同时各自收到一部分数据,如何把数据拼成完整的消息?谁先到达?
  • 如果两个线程同时write同一个TCP socket,每个线程都只发出半条消息,接收方收到数据如何处理?
  • 如果给每个TCP socket配一把锁,让同时只能有一个线程读或写此socket,似乎可以解决问题,但这样不如始终同一个线程来操作此socket来的简单
  • 非阻塞IO,收发消息的原子性不可能用锁来保证,因为这样会阻塞其他IO线程

因此。多线程程序应该遵循的原则是:每个文件描述符只由一个线程操作,从而解决消息收发的顺序性问题,也避免了文件描述符的各种race condition

4.7 用RAII包装文件描述符

4.8 RAII与fork()

fork()之后,子进程继承了父进程的几乎全部状态。不会继承:

  • 父进程的内存锁,mlock、mlockall
  • 父进程的文件锁,fcntl
  • 父进程的某些定时器,setitimer、alarm、timer_create等
  • Others, See man 2 fork

4.9 多线程与fork()

多线程与fork()的协作性很差。这是POSIX系列操作系统的历史包袱。

fork()一般不能在多线程程序中调用,因为linux的fork()只克隆当前线程的thread of control,不克隆其他线程。由此看来,唯一安全的做法是在fork()后立即exec()执行另一个程序,彻底隔断紫禁城与父进程的联系

4.10 多线程与signal

Linux/Unix的信号与多线程可谓是水火不容。在单线程时代,边写信号处理函数就是一件棘手的事情,由于signal打断了正在运行的thread of control,在singnal handler中只能调用“可重入函数”

在多线程程序中,使用signal的第一原则是“不要使用signal”。包括:

  • 不要用signal作为IPC的手段,包括不要用SIGUSR1等信号来触发服务端的行为。
  • 不要使用基于signal实现的定时函数
  • 不出动处理各种异常信号,只用默认语义:结束进程。有一个例外:SIGPIPI,服务器程序通常的做法是忽略此信号,否则如果对方断开连接,而本机继续write的话,会导致程序意外终止
  • 在没有别的替代方法的情况下,把异步信号转换为同步的文件描述符事件。