C++ TinyWebServer项目总结(14. 多线程编程)

早期Linux不支持线程,直到1996年,Xavier Leroy等人开发出第一个基本符合POSIX标准的线程库LinuxThreads,但LinuxThreads效率低且问题多,自内核2.6开始,Linux才开始提供内核级的线程支持,并有两个组织致力于编写新的线程库:NGPT(Next Generation POSIX Threads)和NPTL(Native POSIX Thread Library),但前者在2003年就放弃了,因此新的线程库就是NPTL。NPTL比LinuxThreads效率高,且更符合POSIX规范,所以它已经成为glibc的一部分,本书使用的线程库是NPTL。

本章要讨论的线程相关的内容都属于POSIX线程(简称 pthread)标准,不局限于NPTL实现,包括:

  • 创建线程和结束线程;
  • 读取和设置线程属性;
  • POSIX线程同步方式:POSIX信号量、互斥锁和条件变量。

线程、进程的概念

线程和进程是操作系统中两个基本的执行单元,它们各自有不同的特性和用途。理解它们之间的区别对于开发高效、可靠的软件应用程序至关重要。下面详细阐述它们的主要区别:

1. 定义

  • 进程:进程是操作系统进行资源分配和调度的基本单位,是程序的一次执行过程。它是系统进行资源分配和调度的一个独立单位。每个进程都有自己的地址空间、内存、数据栈以及其他用于跟踪执行的辅助数据。
  • 线程:线程是进程中的实际运行单位,是进程中的一个单一顺序控制流,也被称为轻量级进程(LWP)。一个进程可以包含多个线程,所有线程共享进程的资源,如内存和文件描述符。

2. 资源共享

  • 进程:进程间通常不共享内存或资源,除非通过进程间通信机制(如管道、信号量、共享内存等)显式进行资源共享。13. C++ TinyWebServer项目总结(13. 多进程编程)
  • 线程:同一进程内的所有线程共享进程资源,如内存和文件句柄。这使得线程间的数据共享更容易,但也需要同步机制来避免竞态条件。

3. 独立性

  • 进程:进程是相对独立的,操作系统管理其执行和资源分配。一个进程的崩溃通常不会直接影响其他进程。
  • 线程:线程的独立性较低,一个线程的错误可以影响同一进程中的其他线程,因为它们共享相同的内存空间。

4. 开销

  • 进程:创建新进程的开销比创建线程大得多,因为操作系统需要为新进程分配独立的地址空间和其他资源。
  • 线程:线程的创建、结束和切换的开销较小,因为它们共享大部分进程资源。线程的切换不涉及地址空间的切换,仅涉及上下文切换。

5. 通信

  • 进程:进程间通信(IPC)比较复杂,涉及特定的操作系统支持的机制,如信号、信号量、消息队列、共享内存等。
  • 线程:线程间通信可以通过直接读写进程中的共享数据来实现,但这需要适当的同步操作,如互斥锁(mutex)和条件变量,以防止数据冲突。(见下文)。

Linux 线程概述

线程模型

线程是程序中完成一个独立任务的完整执行序列,即一个可调度的实体。根据运行环境和调度者的身份,线程可分为内核线程和用户线程。

  • 内核线程在有的系统上也称为LWP(Light Weight Process,轻量级进程),运行在内核空间,由内核调度;
  • 用户线程运行在用户空间,由线程库调度。当进程的一个内核线程获得CPU的使用权时,它就加载并运行一个用户线程,可见,内核线程相当于用户线程运行的容器,一个进程可以拥有M个内核线程和N个用户线程,其中M<=N,并且在一个系统的所有进程中,M和N的比值都是固定的。

按照M:N的取值,线程的实现可分为三种模式:完全在用户空间实现、完全由内核调度、双层调度(two level scheduler)。

完全在用户空间实现的线程无须内核的支持,内核甚至不知道这些线程的存在,线程库负责管理所有执行线程,如线程的优先级、时间片等。线程库利用longjmp函数来切换线程的执行,使它们看起来像是并发执行的,但实际上内核仍然是把整个进程作为最小单位来调度,换句话说,一个进程中的所有线程共享该进程的时间片,它们对外表现出相同的优先级(即所有线程使用相同的优先级,因为它们都是以进程为单位调度的)。对这种实现方式而言,M=1,即 N 个用户线程对应一个内核线程,而该内核线程对实际就是进程本身。完全在用户空间实现的线程的优点:创建和调度线程都无需内核干预,因此速度快,且它不占用额外内核资源,所以即使一个进程创建了很多线程,也不会对系统性能造成明显的影响。其缺点是:对于多处理器系统,一个进程的多个线程无法运行在不同的CPU上,因为内核是按照其最小调度单位来分配CPU的,且线程的优先级只在相对于进程内的其他线程生效,比较不同进程内的线程的优先级无意义。

完全由内核调度的模式将创建、调度线程的任务交给了内核,运行在用户空间的线程库无须执行管理任务,这与完全在用户空间实现的线程恰恰相反,因此二者的优缺点也正好互换。较早的Linux内核对内核线程的控制能力有限,线程库通常还要提供额外的控制能力,尤其是线程同步机制,但现代Linux内核已经大大增强了对线程的支持。完全由内核调度的线程实现满足M : N=1 : 1,即1个用户空间线程被映射为1个内核线程。

双层调度模式是两种实现模式的混合体,内核调度M个内核线程,线程库调度N个用户线程,这种线程实现方式结合了前两种方式的优点,不会消耗过多的内核资源,且线程切换速度也较快,同时还能充分利用多处理器优势。

Linux 线程库

Linux上两个有名的线程库是LinuxThreads和NPTL,它们都采用1:1方式实现(完全由内核调度的模式)。现代Linux上默认使用的线程库是NPTL,用户可用以下命令查看当前系统使用的线程库:

getconf GNU_LIBPTHREAD_VERSION

LinuxThreads线程库的内核线程是用clone系统调用创建的进程模拟的,clone系统调用和fork系统调用的作用类似,都创建调用进程的子进程,但我们可以为clone系统调用指定CLONE_THREAD标志,此时它创建的子进程与调用进程共享相同的虚拟地址空间、文件描述符、信号处理函数,这些都是线程的特点,但用进程模拟内核线程会导致很多语义问题:

  • 每个线程拥有不同的PID,不符合POSIX规范。
  • Linux信号处理本来是基于进程的,但现在一个进程内部的所有线程都能且必须处理信号。
  • 用户ID、组ID对一个进程中的不同线程来说可能不同。
  • 进程产生的核心转储文件不会包含所有线程的信息,而只包含该核心转储文件的线程的信息。
  • 由于每个线程都是一个进程,因此系统允许的最大进程数就是最大线程数。

LinuxThreads线程库一个有名的特性是所谓的管理线程,它是进程中专门用于管理其他工作线程的线程,其作用为:

  • 系统发送给进程的终止信号先由管理线程接收,管理线程再给其他工作线程发送同样的信号以终止它们。
  • 当终止工作线程或工作线程主动退出时,管理线程必须等待它们结束,以避免僵尸进程。
  • 如果主线程即将先于其他工作线程退出,则管理线程将阻塞主线程,直到所有其他工作线程都结束后才唤醒它。
  • 回收每个线程堆栈使用的内存。

管理线程的引入,增加了额外的系统开销,且由于管理线程只能运行在一个CPU上,所以LinuxThreads线程库不能充分利用多处理器系统的优势(所有管理操作只能在一个CPU上完成)。

要解决LinuxThreads线程库的一系列问题,不仅需要改进线程库,最主要的是需要内核提供更完善的线程支持,因此Linux内核从2.6版本开始,提供了真正的内核线程,新的NPTL线程库也应运而生,相比LinuxThreads,NPTL的主要优势在于:

  • 内核线程不再是一个进程,因此避免了很多用进程模拟线程导致的语义问题。
  • 摒弃了管理线程,终止线程、回收线程堆栈等工作都可以由内核完成。
  • 由于不存在管理线程,所以一个进程的线程可以运行在不同CPU上,从而充分利用了多处理器系统的优势。
  • 线程的同步由内核来完成,隶属于不同进程的线程之间也能共享互斥锁,因此可实现跨进程的线程同步。

创建和结束线程

创建和结束线程的API在Linux上定义在pthread.h头文件。

pthread_create

pthread_create函数创建一个线程:

#include <pthread.h>
int pthread_create(pthread_t* thread, count pthread_attr_t* attr, 
                    void* (*start_routine)(void*), void* arg);
参数

thread:新线程的标识符,后续pthread_*函数通过它来引用新线程,其类型pthread_t定义如下:

#include <bits/pthreadtypes.h>
typedef unsignde long int ptherad_t;

attr:用于设置新线程的属性,给它传递NULL表示使用默认线程属性。

start_routinearg参数分别指定新线程将运行的函数及其参数。

返回值

pthread_create函数成功时返回0,失败时返回错误码。

一个用户可以打开的线程数不能超过RLIMIT_NPROC软资源限制,此外,系统上所有用户能创建的线程总数也不能超过/proc/sys/kernel/threads-max内核参数定义的值。

pthread_exit

线程一旦被创建,内核就可以调度内核线程来执行start_routine函数指针参数所指向的函数了,线程函数在结束时最好调用pthread_exit函数,以确保安全、干净地退出:

#include <pthread.h>
void pthread_exit(void* retval);

pthread_exit函数会通过retval参数向线程的回收者传递其退出信息,它执行完后不会返回到调用者,且永远不会失败。

pthreda_join

一个进程中的所有线程都能调用pthread_join函数来回收其他线程(前提是目标线程是可回收的),即等待其他线程结束,这类似回收进程的waitwaitpid系统调用:

#include <pthread.h>
int pthread_join(pthread_t thread, void** retval);
参数

thread:目标线程的标识符。

retval:目标线程返回的退出信息。

返回值

pthread_join函数会一直阻塞,直到被回收的线程结束为止,该函数成功时返回0,失败则返回错误码。

pthread_cancel

有时候我们希望异常终止一个线程,即取消线程,它是通过pthread_cancel函数实现的:

#include <ptrhead.h>
int pthread_cancel(pthread_t thread);
参数

thread:目标线程的标识符。

返回值

pthread_cancel函数成功时返回0,失败则返回错误码。

接收到取消请求的目标线程可以决定是否允许被取消以及如何取消,这通过以下函数完成:

#include <pthread.h>
int pthread_setcancelstate(int state, int *oldstate);
int pthread_setcanceltype(int type, int *oldtype);

线程属性

pthread_attr_t结构体定义了一套完整的线程属性:

#include <bits/pthreadtypes.h>
#define __SIZEOF_PTHREAD_ATTR_T 36
typedef union{
	char __size[__SIZEOF_PTHREAD_ATTR_T];
	long int __align;
} pthread_attr_t;

线程的各种属性都包括在一个字符数组里,线程库定义了一系列函数来操作pthread_attr_t类型变量,方便我们设置和获取线程属性:

#include <pthread.h>

int pthread_attr_init ( pthread_attr_t* attr ); /* 初始化线程属性对象 */
int pthread_attr_destroy(pthread_attr_t* attr ); /* 销毁线程属性对象。被销毁的线程属性对象只有再次初始化之后才能继续使用 */

/* 下面这些函数用于获取和设置线程属性对象的某个属性 */
int pthread_attr_getdetachstate ( const pthread_attr_t* attr, int* detachstate );
int pthread_attr_setdetachstate( pthread_attr_t* attr, int detachstate );
int pthread_attr_getstackaddr( const pthread_attr_t* attr,void ** stackaddr );
int pthread_attr_setstackaddr( pthread_attr_t* attr, void* stackaddr );
int pthread_attr_getstacksize(const pthread_attr_t* attr, size_t* stacksize );
int pthread_attr_setstacksize(pthread_attr_t* attr, size_t stacksize);                        
int pthread_attr_getstack ( const pthread_attr_t* attr, void** stackaddr, size_t* stacksize);

/* 还有很多,不在这里一一列举了 */

这部分每个线程属性的详细含有见《Linux 高性能服务编程》第 14 章 多线程编程 P275。

POSIX 信号量

和多进程程序一样,多线程程序也要考虑同步问题。pthread_join函数可看作一种简单的线程同步方式(用于回收其他线程 / 等待其他线程结束),但它无法高效实现复杂的同步需求,比如控制对共享资源的独占式访问,或者是满足某个条件后唤醒一个线程。下面讨论3种专门用于线程的同步机制:POSIX信号量、互斥量、条件变量。

在Linux上,信号量API有两组,一组是System V IPC信号量(信号量),另一组是我们要讨论的POSIX信号量。这两组接口很相似,且语义完全相同,但不保证能互换。

POSIX信号量函数的名字都以sem_开头,不像大多线程函数那样以pthread_开头。常用的POSIX信号量函数如下:

#include <semaphore.h>
int sem_init(sem_t* sem, int pshared, unsigned int value);	/* 初始化一个未命名信号量 */
int sem_destory(sem_t* sem);								/* 销毁信号量 */
int sem_wait(sem_t* sem);									/* 以原子操作的方式将信号量的值减1 */
int sem_trywait(sem_t *sem);								/* 相当于sem_wait函数的非阻塞版本 */
int sem_post(sem_t *sem);									/* 以原子操作的方式将信号量的值加1 */

上图中函数的第一个参数sem指向被操作的信号量。

  • sem_init函数用于初始化一个未命名信号量(POSIX信号量API支持命名信号量,但本书不讨论)。pshared参数指定信号量类型,如果值为0,就表示这个信号量是当前进程的局部信号量,否则该信号量可以在多个进程间共享。value参数指定信号量的初始值。初始化一个已经被初始化的信号量将导致不可预期的结果。
  • sem_destroy函数用于销毁信号量,以释放其占用的内核资源,销毁一个正被其他线程等待的信号量将导致不可预期的结果。
  • sem_wait函数以原子操作的方式将信号量的值减1,如果信号量的值为0,则sem_wait函数将被阻塞,直到这个信号量具有非0值。
  • sem_trywait函数与sem_wait函数类似,但它始终立即返回,而不论被操作的信号量是否具有非0值,相当于sem_wait函数的非阻塞版本。当信号量非0时,sem_trywait函数对信号量执行减1操作,当信号量的值为0时,该函数返回-1并设置errno为EAGAIN
  • sem_post函数以原子操作的方式将信号量的值加1,当信号量的值从0变为1时,其他正在调用sem_wait等待信号量的线程将被唤醒。

上图中的函数成功时返回0,失败则返回-1并设置errno。

互斥锁

互斥锁(也称互斥量)用于保护关键代码段,以确保其独占式的访问,这有些像二进制信号量(信号量),当进入关键代码段时,我们需要获得互斥锁并将其加锁,这等价于二进制信号量的P操作;当离开关键代码段时,我们需要对互斥锁解锁,以唤醒其他等待该互斥锁的线程,这相当于二进制信号量的V操作。

互斥锁基础 API

#include <pthread.h>
int pthread_mutex_init ( pthread_mutex_t* mutex,					/* 初始化互斥锁 */
                            const pthread_mutexattr_t* mutexattr );	
int pthread_mutex_destory ( pthread_mutex_t* mutex );				/* 销毁互斥锁 */
int pthread_mutex_lock ( pthread_mutex_t* mutex );					/* 以原子操作的方式给一个互斥锁加锁 */
int pthread_mutex_trylock ( pthread_mutex_t* mutex );				/* 相当于 pthread_mutex_lock 的非阻塞版本 */
int pthread_mutex_unlock ( pthread_mutex_t* mutex );				/* 以原子操作的方式给一个互斥锁解锁 */

互斥锁属性

pthread_mutex_t结构体描述互斥锁的属性,线程库提供了一系列函数来操作pthread_mutexattr_t类型的变量,以方便我们获取和设置互斥锁属性,以下是其中一些主要的函数:

#include <pthread.h>

/* 初始化互斥锁属性对象 */
int pthread_mutexattr_init ( pthread_mutexattr_t* attr );

/* 销毁互斥锁属性对象 */
inrt pthread_mutexattr_destroy ( pthread_mutexattr_t* attr );

/* 获取和设置互斥锁的 pshared 属性 */
int pthread_mutexattr_getpshared ( const pthread_mutexattr_t* attr, int* pshared );
int pthread_muextattr_setpshared ( pthread_mutexattr_t* attr, int* pshared );

/* 获取和设置互斥锁的 type 属性 */
int pthread_mutexattr_gettype ( const pthread_mutexattr_t* attr, int* type );
int pthread_mutexattr_settype ( pthread_mutexattr_t* attr, int* type );

本书仅讨论互斥锁的两种常用属性:psharedtype

互斥锁属性pshared指定是否允许跨进程共享互斥锁,其可选值为:

  • PTHREAD_PROCESS_SHARED:互斥锁可以被跨进程共享。
  • PTHREAD_PROCESS_PRIVATE:互斥锁只能和锁的初始化线程隶属于同一个进程的线程共享。

互斥锁属性type指定互斥锁的类型,Linux支持以下4种互斥锁:

  • PTHREAD_MUTEX_NORMAL:普通锁,这是互斥锁的默认类型。当一个线程对一个普通锁加锁后,其余请求该锁的线程将形成一个等待队列,并在该锁解锁后按优先级获得它。这种锁类型保证了资源分配的公平性,但也容易引发问题:一个线程如果对一个已经加锁的普通锁再次加锁,将引发死锁;对一个已经被其他线程加锁的普通锁解锁,或者对一个已经解锁的普通锁再次解锁,将导致不可预期的后果。
  • PTHREAD_MUTEX_ERRORCHECK:检错锁。一个线程如果对一个自己加锁的检错锁再次加锁,则加锁操作返回EDEADLK。对一个已经被其他线程加锁的检错锁解锁,或对一个已经解锁的检错锁再次解锁,则解锁操作返回EPERM
  • PTHREAD_MUTEX_RECURSIVE:嵌套锁。这种锁允许一个线程在释放锁前多次对它加锁而不发生死锁,但如果其他线程要获得这个锁,则当前锁的拥有者必须执行相应次数的解锁操作。对一个已经被其他线程加锁的嵌套锁解锁,或对一个已经解锁的嵌套锁再次解锁,则解锁操作将返回EPERM
  • PTHREAD_MUTEX_DEFAULT:默认锁。通常被映射为以上三种锁之一。

实战 11:死锁举例

死锁使一个或多个线程被挂起而无法继续执行,且这种情况还不容易被发现。在一个线程中对一个已经加锁的普通锁再次加锁将导致死锁。另外,如果两个线程按照不同顺序来申请两个互斥锁,也容易产生死锁,如以下代码所示:

#include <pthread.h>
#include <unistd.h>
#include <stdio.h>

int a = 0;
int b = 0;
pthread_mutex_t mutex_a;
pthread_mutex_t mutex_b;

void *another (void *arg) {
    pthread_mutex_lock(&mutex_b);	/* 子线程上锁 mutex_b */
    printf("in child thread, got mutex b, waiting for mutex a\n");
    sleep(5);
    ++b;
    pthread_mutex_lock(&mutex_a);	/* 子线程上锁 mutex_a */
    b += a++;
    
    pthread_mutex_unlock(&mutex_a);	/* 解锁 */
    pthread_mutex_unlock(&mutex_b);
    pthread_exit(NULL);
}

int main () {
    pthread_t id;

    pthread_mutex_init(&mutex_a, NULL);	/* 初始化互斥锁 */
    pthread_mutex_init(&mutex_b, NULL);
    pthread_create(&id, NULL, another, NULL);	/* 创建线程 */

    pthread_mutex_lock(&mutex_a);	/* 主线程上锁 mutex_a */
    printf("in parent thread, got mutex a, waiting for mutex b\n");
    sleep(5);
    ++a;
    
    pthread_mutex_lock(&mutex_b);	/* 主线程上锁 mutex_b */
    a += b++;
    
    pthread_mutex_unlock(&mutex_b);
    pthread_mutex_unlock(&mutex_a);

    /* 主线程等待子线程结束,然后销毁互斥锁以释放资源 */
    pthread_join(id, NULL);
    pthread_mutex_destroy(&mutex_a);
    pthread_mutex_destroy(&mutex_b);

    return 0;
}

由于两个线程都在等待对方已经持有的锁释放,因此会发生死锁,两个线程都将永远等待下去。 为了避免死锁,应确保所有线程以相同的顺序获取互斥锁。

代码位于:

编译:-lpthread 选项确保链接了 POSIX 线程库。

g++ -o test test.cpp -lpthread

条件变量

如果说互斥锁是用于同步线程对共享数据的访问,那么条件变量则是用于在线程之间同步共享数据的值。条件变量提供了一种线程间的通知机制:当某个共享数据达到某个值时,唤醒等待这个共享数据的线程。

条件变量的相关函数如下:

#incldue <pthread.h>

/* 初始化条件变量 */
int pthread_cond_init (pthread_cond_t* cond, const pthread_condattr_t* cond_attr);

/* 销毁条件变量 */
int pthread_cond_destroy (pthread_cond_t* cond);

/* 以广播方式唤醒所有等待目标条件的线程 */
int pthread_cond_broadcast (pthread_cond_t* cond);

/* 唤醒一个等待目标条件变量的线程,唤醒哪个线程取决于线程的优先级和调度策略 */
int pthread_cond_signal (pthread_cond_t* cond);

/* 等待目标条件变量 */
int pthread_cond_wait (ptread_cond_t* cond, pthread_mutex_t* mutex);

条件变量是多线程编程中用于同步的一种机制,它允许线程在某些条件未被满足时暂停执行,并在条件满足时被唤醒继续执行。条件变量通常与互斥锁(mutexes)一起使用,以协调对共享资源的访问。这是一种避免忙等(busy-waiting)并减少CPU资源浪费的有效方式。

工作原理

  1. 等待条件变量:当线程需要访问某个共享资源,但条件不满足时,它会通过互斥锁保护条件变量,并在该条件变量上等待。在这个等待过程中,线程会释放互斥锁,以便其他线程可以修改这个条件。
  2. 唤醒等待的线程:其他线程在修改了条件之后,可以通过条件变量来唤醒一个或多个正在等待这个条件的线程。
  3. 重新检查条件:被唤醒的线程会重新获取互斥锁,并再次检查条件是否满足。如果条件满足,线程继续执行;如果不满足,线程可能会再次等待。

实战 12:使用条件变量模拟实现生产者—消费者问题

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

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;	/* 初始化互斥锁 */
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;		/* 初始化条件变量 */
int buffer = 0;										/* 产品,代表共享资源 */

/* 生产者线程 */
void* producer(void* arg) {
    pthread_mutex_lock(&mutex);
    buffer = 1; // 生产产品
    printf("Producer: Produced an item\n");
    pthread_cond_signal(&cond); // 通知消费者
    pthread_mutex_unlock(&mutex);
    
    return NULL;
}

/* 消费者线程 */
void* consumer(void* arg) {
    pthread_mutex_lock(&mutex);
    while (buffer == 0) {
        printf("Consumer: Waiting for item\n");
        pthread_cond_wait(&cond, &mutex); // 等待产品,自动释放互斥锁并使线程进入等待状态
    }
    buffer = 0; // 消费产品
    printf("Consumer: Consumed an item\n");
    pthread_mutex_unlock(&mutex);
    
    return NULL;
}

int main() {
    
    /* 创建线程 */
    pthread_t prod, cons;
    pthread_create(&prod, NULL, producer, NULL);
    pthread_create(&cons, NULL, consumer, NULL);

    /* 等待线程结束 */
    pthread_join(prod, NULL);
    pthread_join(cons, NULL);

    /* 资源释放 */
    pthread_mutex_destroy(&mutex);
    pthread_cond_destroy(&cond);

    return 0;
}

生产者进程:

  • 生产者线程首先通过 pthread_mutex_lock 获取互斥锁,这是必要的步骤来保护对 buffer 变量的写操作。
  • 生产者将 buffer 的值设置为 1,表示生产了一个产品。
  • 生产完成后,生产者调用 pthread_cond_signal 来唤醒可能在等待 cond 条件变量的消费者线程。
  • 最后,生产者释放互斥锁,允许其他线程(如消费者)获取互斥锁以访问 buffer

消费者进程:

  • 消费者线程也首先锁定互斥锁。
  • 消费者检查 buffer 是否为 0(表示没有产品可消费)。如果是这样,消费者调用 pthread_cond_wait,这个函数会自动释放互斥锁并使线程进入等待状态,直到生产者通过 pthread_cond_signalpthread_cond_broadcast 唤醒它。
  • 当消费者被唤醒并从 pthread_cond_wait 返回时,它会自动重新获得互斥锁。
  • 消费者将 buffer 设置为 0,表示消费掉了产品,并输出相应的消息。
  • 最后,消费者释放互斥锁。

测试代码

编译:-pthread链接线程库。

g++ -o pcer producer-consumer.cpp -pthread

正常运行显示:

表示没有发生死锁。

线程同步机制包装类

为了充分复用代码,同时后文需要,我们将前面讨论的三种线程同步机制分别封装为三个类,实现在locker.h头文件中:

#ifndef LOCKER_H
#define LOCKER_H

#include <exception>
#include <pthread.h>
#include <semaphore.h>

// 封装信号量的类
class sem {
public:
    // 创建并初始化信号量
    sem() {
        if (sem_init(&m_sem, 0, 0) != 0) {
            // 构造函数没有返回值,可通过抛出异常来报告错误
            throw std::exception();
        }
    }
 
    // 销毁信号量
    ~sem() {
        sem_destroy(&m_sem);
    }
 
    // 等待信号量
    bool wait() {
        return sem_wait(&m_sem) == 0;
    }
 
    // 增加信号量
    bool post() {
        return sem_post(&m_sem) == 0;
    }

private:
    sem_t m_sem;
};

// 封装互斥锁的类
class locker {
public:
    // 创建并初始化互斥锁
    locker() {
        if (pthread_mutex_init(&m_mutex, NULL) != 0) {
            throw std::exception();
        }
    }

    // 销毁互斥锁
    ~locker() {
        pthread_mutex_destroy(&m_mutex);
    }

    // 获取互斥锁
    bool lock() {
        return pthread_mutex_lock(&m_mutex) == 0;
    }

    // 释放互斥锁
    bool unlock() {
        return pthread_mutex_unlock(&m_mutex) == 0;
    }

private:
    pthread_mutex_t m_mutex;
};

// 封装条件变量的类
class cond {
public:
    // 创建并初始化条件变量
    cond() {
        if (pthread_mutex_init(&m_mutex, NULL) != 0) {
            throw std::exception();
        }
        if (pthread_cond_init(&m_cond, NULL) != 0) {
            // 构造函数中一旦出现问题,就应立即释放已经成功分配的资源
            pthread_mutex_destroy(&m_mutex);
            throw std::exception();
        }
    }

    // 销毁条件变量
    ~cond() {
        pthread_mutex_destroy(&m_mutex);
        pthread_cond_destroy(&m_cond);
    }

    // 等待条件变量
    bool wait() {
        int ret = 0;
        // 作者在此处对互斥锁加锁,保护了什么?这导致其他人无法使用该封装类
        pthread_mutex_lock(&m_mutex);
        ret = pthread_cond_wait(&m_cond, &m_mutex);
        pthread_mutex_unlock(&m_mutex);
        return ret == 0;
    }

    // 唤醒等待条件变量的线程
    bool signal() {
        return pthread_cond_signal(&m_cond) == 0;
    }

private:
    pthread_mutex_t m_mutex;
    pthread_cond_t m_cond;
};

#endif

多线程环境

可重入函数

可重入函数

如果一个函数能被多个线程同时调用且不发生竞态条件,则我们称它是线程安全的(thread safe),或者说它是可重入函数。Linux库函数只有一小部分是不可重入的。这些库函数之所以不可重入,主要是因为其内部使用了静态变量,但Linux对很多不可重入的库函数提供了对应的可重入版本,这些可重入版本的函数名是在原函数名尾部加上_r,如localtime函数对应的可重入函数是localtime_r

在多线程程序中调用库函数,一定要使用其可重入版本,否则可能导致预想不到的结果。

实战 13:多线程环境中,使用fork调用产生的死锁问题

如果多线程的某个线程(可以理解为一个进程)调用了fork函数,那么新创建的子进程只拥有一个执行线程,它是调用fork的那个线程的完整复制,且子进程将自动继承父进程中互斥锁、条件变量的状态,即父进程中已被加锁的互斥锁在子进程中也是被锁住的,这就引起了一个问题:子进程可能不清楚从父进程继承而来的互斥锁的具体状态(是加锁还是解锁状态),这个互斥锁可能被加锁了,但不是由调用fork的线程锁住的,而是由其他线程锁住的,此时,子进程若再次对该互斥锁加锁会导致死锁,如以下代码所示:

#include <pthread.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <wait.h>

pthread_mutex_t mutex;

/* 子线程运行的函数,它首先获得互斥锁 mutex ,然后暂停5s,再释放该互斥锁 */
void *another(void *arg) {
    printf("in child thread, lock the mutex\n");
    pthread_mutex_lock(&mutex);
    sleep(5);
    pthread_mutex_unlock(&mutex);
}

int main() {
    pthread_mutex_init(&mutex, NULL);
    pthread_t id;
    pthread_create(&id, NULL, another, NULL);
    /* 父进程中的主线程暂停1s,以确保在执行 fork 前,子线程已经开始运行并获得了互斥量 mutex */
    sleep(1);
    int pid = fork();
    if (pid < 0) {
        pthread_join(id, NULL);
        pthread_mutex_destroy(&mutex);
        return 1;
    } else if (pid == 0) {
        printf("I am in the child, want to get the lock\n");
        /* 子进程从父进程继承了互斥锁 mutex 的状态,该互斥锁处于锁住的状态 */
        /* 这是由父进程中的子线程执行 pthread_mutex_lock 引起的,因此以下加锁操作会一直阻塞 */
        /* 尽管从逻辑上来说它是不应该阻塞的 */
        pthread_mutex_lock(&mutex);
        printf("I can not run to here, oop...\n");
        pthread_mutex_unlock(&mutex);
        exit(0);
    } else {
        wait(NULL);
    }
    pthread_join(id, NULL);
    pthread_mutex_destroy(&mutex);
    return 0;
}

关键点

  1. 子线程加锁:在主线程(进程)中创建的子线程首先获取互斥锁并休眠5秒。
  2. fork 调用:在子线程获取互斥锁后,主线程休眠1秒以确保子线程锁定互斥锁,然后调用 forkfork 之后,父进程和子进程都有一个拷贝的互斥锁状态。
  3. 子进程中的锁行为:由于 fork 后子进程继承了互斥锁的状态,如果该锁被锁定,子进程中的互斥锁也将处于锁定状态。不同的是,子进程中并没有线程拥有这个锁(因为锁的拥有者是父进程的一个线程),因此尝试获取这个锁将会导致子进程永久阻塞。

效果:子进程被阻塞。

不过,pthread提供了一个专门的函数pthread_atfork,以确保fork调用后父进程和子进程都拥有一个清楚的锁状态:

#include <pthread.h>
int pthread_atfork(void (*prepare)(void), void (*parent)(void), void (*child) (void));

pthread_atfork函数将建立3个fork句柄帮助我们清理互斥锁的状态。

  • prepare句柄将在fork函数创建出子进程前被执行,它可以用来锁住父进程中的互斥锁。
  • parent句柄是fork函数创建出子进程后,fork函数返回前,在父进程中被执行,它的作用是释放所有在prepare句柄中被锁住的互斥锁。
  • child句柄是在fork函数返回前,在子进程中执行,它和parent句柄一样,也是用于释放所有在prepare句柄中被锁住的互斥锁。

该函数成功时返回0,失败则返回错误码。

要让以上代码正常工作,需要在fork调用前加上以下代码:

void prepare () {
    pthread_mutex_lock ( &mutex );
}

void infork () {
    pthread_mutex_unlock ( &mutex );
}

pthread_atfork ( prepare, infork, infork );

效果:未发生死锁。

线程和信号

每个线程都能独立设置信号掩码,进程设置信号掩码的函数是sigprocmask(见信号掩码),但在多线程环境下应使用pthread_sigmask函数设置信号掩码:

#include <pthread.h>
#include <signal.h>
int pthread_sigmask ( int how, const sigset_t* newmask, sigset_t* oldmask );

pthread_sigmask函数的参数与sigprocmask函数的参数完全相同。pthread_sigmask函数成功时返回0,失败返回错误码。

由于进程中所有线程共享该进程的信号,所以线程库将根据线程掩码决定把信号发送给哪个具体的线程。而且,所有线程共享信号处理函数,当我们在一个线程中设置了某个信号的信号处理函数后,它将覆盖其他线程为同一信号设置的信号处理函数。

因此,我们应该定义一个专门的线程来处理所有信号,这可通过以下两个步骤实现:

  1. 在主线程创建出其他子线程前就调用pthread_sigmask来设置好信号掩码,所有新创建的子线程将自动继承这个信号掩码,这样,所有线程都不会响应被屏蔽的信号了。
  2. 在某个线程中调用以下函数等待信号并处理:
#include <signal.h>
int sigwait ( const sigset_t* set, int* sig );

set参数指定要等待的信号的集合,我们可以将其指定为在第 1 步中创建的信号掩码,表示在该线程中等待所有被屏蔽的信号。参数sig指向的整数用于存储该函数返回的信号值。sigwait成功时返回0,失败则返回错误码。一旦sigwait函数成功返回,我们就能对收到的信号做处理了,显然,如果我们使用了sigwait函数,就不应再为信号设置信号处理函数了。

pthread还提供了pthread_kill函数,使我们可以把信号发送给指定线程:

#include <signal.h>
int pthread_kill ( pthread_t thread, int sig );

thread参数指定目标线程。sig参数指定待发送信号,如果sig参数为0,则pthread_kill不发送信号,但它仍会进行错误检查。我们可用此方法检查目标线程是否存在。pthread_kill函数成功时返回0,失败则返回错误码。

实战 14:在一个线程中统一处理所有信号

以下代码取自pthread_sigmask函数的man手册,它展示了如何通过以上两个步骤实现在一个线程中统一处理所有信号:

主线程设置了一个信号掩码来阻塞特定的信号(在这个例子中是SIGQUITSIGUSR1),然后创建一个专门的线程来处理这些信号。这种模式是处理多线程环境中信号的推荐方式,因为它避免了信号处理和线程执行之间的竞争条件。

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <errno.h>

// perror函数根据全局errno值打印其相应的错误信息到标准错误
#define handle_error_en(en, msg) \ 
    do { errno = en; perror(msg); exit(EXIT_FAILURE); } while (0)

/* 在 sig_thread 函数中,线程循环调用 sigwait 来等待信号 */
static void *sig_thread(void *arg) {
    sigset_t *set = (sigset_t *)arg;
    int s, sig;

    for (; ; ) {
        // 第二步,调用sigwait等待信号
        s = sigwait(set, &sig);
        if (s != 0) {
            handle_error_en(s, "sigwait");
        }
        printf("Signal handling thread got signal %d\n", sig);
    }
}

int main(int argc, char *argv[]) {
    printf("The PID of this process is: %d\n", getpid()); /* 获取进程 PID */
    
    pthread_t thread;			/* 线程 */
    sigset_t set;				/* 信号集 */
    int s;

    /* 第一步,在主线程中设置信号掩码,信号集set被初始化并添加了SIGQUIT和SIGUSR1信号: */
    sigemptyset(&set);
    sigaddset(&set, SIGQUIT);
    sigaddset(&set, SIGUSR1);

    /* 使用 pthread_sigmask 来阻塞这些信号 */
    s = pthread_sigmask(SIG_BLOCK, &set, NULL);
    if (s != 0) {
        handle_error_en(s, "pthread_sigmask");
    }

    /* 创建处理信号的线程 */
    s = pthread_create(&thread, NULL, &sig_thread, (void *)&set);
    if (s != 0) {
        handle_error_en(s, "thread_create");
    }

    pause();
}

编译:

g++ -o signal signal.cpp -pthread

运行程序,并在另一个终端中使用kill命令发送SIGQUITSIGUSR1信号到程序。例如:

kill -SIGQUIT [pid]
kill -SIGUSR1 [pid]

效果:

参考文章

  1. Linux高性能服务器编程 学习笔记 第十四章 多线程编程_哪个版本开始 线程在不同的cpu上运行-CSDN博客
  2. Linux高性能服务器编程-游双——第十四章多线程编程_linux多线程编程 游双-CSDN博客
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

红茶川

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值