C++ Webserver从零开始:基础知识(八)——多线程编程

线程概述

现代Linux系统主要使用的线程库是NPTL,在Linux中可以通过

getconf GNU_LIBPTHREAD_VERSION

获取Linux线程库版本。谈到线程,就不得不提及线程模型

线程模型

本小节概念较多,采用问答式进行讲解,以帮助读者理解

什么是线程?

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

内核线程和用户线程的区别和联系?

内核线程运行在内核空间,由内核来调度。用户线程运行在用户空间,由线程库来调度。

当一个内核线程获得CPU的使用权时,它就会加载并运行一个用户线程,所以从某种角度上来说,内核线程可以看作用户线程运行的“容器”。

线程与进程的关系?

一个进程可以拥有多个线程,假设该进程的线程分为M个内核线程和N个用户线程,那么这些线程符合以下规律:

  • M <= N
  • 在一个系统的所有进程中,MN的比值固定

而且根据MN的比值(M:N),可以划分出三种线程模型,其分别是:

  • 完全在用户空间实现
  • 完全由内核调度

双层调度

三种线程模型?

  • 完全在用户空间实现:完全在用户空间实现的线程无需内核支持,内核也不知道这些用户空间的线程存在。由线程库负责所有线程的优先级,时间片调度。对于内核来说仍把整个进程当作最小单位来调度,不区分里面具体的线程。
    • 优点:创建和调度线程无需内核干预,不占据内核资源,速度快缺点:对于多处理器系统,一个进程的多个线程无法运行在不同的CPU上
  • 完全由内核调度:完全由内核调度的模式将创建,调度的任务全部交给了内核,内核空间的线程库无具体任务。
    • 优缺点:与完全在用户空间实现的优缺点正好相反
  • 双层调度:前两种实现模式的混合体,结合了前两种模式的优点,不会消耗太多的内核资源,切换线程速度较快,可以充分利用多处理器优势


创建线程和结束线程

现在我们来学习线程管理的API,Linux中他们都定义在phtread.h头文件中

pthread_create

#include<pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t* attr, void*(*start_routinue)(void*), void* arg);
  • 作用:创建一个线程
  • 参数
    • thread:线程标识符
    • attr:设置新线程的属性
    • start_routine:指定线程运行的函数
    • arg:指定线程运行的函数的参数
  • 返回值
    • 成功:0
    • 失败:错误码

pthread_exit

线程一旦创建好,内核就会调度内核线程执行start_coutine函数指针所指向的函数,执行完毕之后最好执行退出函数。

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

该函数能退出线程,并且执行完毕后不会返回,且不会执行失败。

pthread_join

一个进程内的线程都可以调用pthread_join来回收其他线程,类似于wait和waitpid调用

#include<pthread.h>
int pthread_join(pthread_t thread, void** retval);
  • 作用:回收其他线程
  • 参数:
    • thread:目标线程的标识符
    • retval:目标线程返回的退出信息
  • 返回值:
    • 成功:0
    • 失败:错误码

pthread_cancel

#include<pthread.h>
int pthread_cancel(pthread_t thread);

该函数的作用是异常终止线程,即取消线程。其中thread参数是目标线程标识符,成功返回0,失败返回错误码。

接收到取消请求的目标线程可以决定本线程是否允许被取消,以及如何取消。

#include<pthread.h>
int pthread_setcancelstate(int state, int* oldstate);
int pthread_setcanceltype(int type, int* oldtype);
  • 作用:决定本线程是否可以被取消以及如何被取消
  • 参数:
    • state:是否允许被取消状态 type:如何取消状态
    • oldstate:记录线程原来的是否允许被取消状态 oldtype:记录线程原来的如何取消状态
  • 返回值:
    • 成功:0
    • 失败:错误码


线程属性

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;

Linux中各线程属性就定义在该字符数组中,线程库定义了一系列函数来操作pthread_attr_t类型的变量来获取和设置线程属性。

这部分内容实在太多,更多内容可以自行搜索,也可以把glibc git到本地目录查看

我在站内找到一个文章供参考线程属性pthread_attr_t简介_c语言中 pthread_attr_t-CSDN博客


POSIX信号量

就如多进程的同步问题一样,线程也必须考虑同步问题。在进程中,我们介绍的是IPC信号量,而线程中往往采用的是POSIX信号量,这两组接口的语义完全相同

#include<semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
int sem_destroy(sem_t *sem);

int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);

int sem_post(sem_t *sem);
  • 作用:
    • sem_init:初始化一个未命名的信号量
    • sem_destroy:销毁信号量,释放其内核资源
    • sem_wait:函数以原子操作的方式将信号量值 - 1,若信号量值为0则阻塞
    • sem_trywait:函数以原子操作的方式将信号量-1,但是非阻塞,如果信号量为0则返回 -1
    • sem_post:函数以原子操作将信号量+1
  • 参数:
    • sem:指向被操作的信号量
    • pshared:信号量的类型
    • value:信号量的初始值
  • 返回值:
    • 成功:0
    • 失败:-1


互斥锁

互斥锁用于保护关键代码段,其操作类似二进制信号量。当进入关键代码段时,我们要获得锁并对其枷锁,类似于二进制信号量的P操作;当离开关键代码段时,我们需要对互斥锁解锁,以唤醒其他等待互斥锁的线程,等价于二进制信号量的V操作。

基础API

int pthread_mutex_init(pthread_mutex_t *restrict mutex,
    const pthread_mutexattr_t *restrict attr);
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int pthread_mutex_destroy(pthread_mutex_t *mutex);

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
  • 作用:
    • pthread_mutex_init:初始化互斥锁
      • pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER:另一个初始化互斥锁的方式,整个宏会将互斥锁的各个字段都初始化为 0
    • pthread_mutex_destroy: 销毁互斥锁,释放其系统资源。若直接销毁一个加锁的互斥锁会导致不可预期的后果
    • pthread_mutex_lock:以原子操作给互斥锁枷锁,若已锁上会进入阻塞状态
    • pthread_mutex_trylock:同上,但不会进入阻塞状态,若已锁上会返回错误码EBUSY
    • pthread_mutex_unlock::以原子操作给互斥锁解锁
  • 参数:
    • mutex:指向要操作的目标互斥锁
    • mutexattr:指定互斥锁的属性
  • 返回值:
    • 成功:0
    • 失败:错误码

互斥锁属性

phtread_mutexattr_t结构体定义了一套完整的互斥锁属性,而线程库也提供了一系的函数来操作这些属性变量。一些主要函数:

#include <pthread.h>
/*初始化互斥锁*/
int pthread_mutexattr_init(pthread_mutexattr_t *attr);
/*销毁互斥锁*/
int pthread_mutexattr_destroy(pthread_mutexattr_t *attr);

/*获取和设置互斥锁的pshared属性*/
int pthread_mutexattr_getpshared(const pthread_mutexattr_t *attr, int *pshared);
int pthread_mutexattr_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);

互斥锁有pshared和type两种常用属性

pshared:

PTHREAD_PROCESS_SHARED:互斥锁可以被跨进程gongxiang

PTHREAD_PROCESS_PRIVATE:互斥锁只能被和锁的初始化线程隶属于一个进程的线程共享

type:

PTHREAD_MUTEX_NORMAL:普通锁,当一个线程对一个普通锁加锁后会形成等待队列,并在该锁解锁后按优先级获得它。这中锁容易引发问题:当线程对一个以及枷锁的普通锁再次加锁,将引发死锁;对一个已经被其他线程加锁的普通锁解锁,或者对一个已经解锁的普通锁再枷锁,将导致不可预期的后果

PTHREAD_MUTEX_ERRORCHECK:检错锁,若一个线程对已经加锁的检错锁再次加锁,则返回EDEADLK,对一个已经被其他线程加锁的检错锁解锁,或者对一个已经解锁的检错锁再次解锁,将返回EPERM。

PHTREAD_MUTEX_RECURSIVE:嵌套锁,这种锁允许一个线程在释放锁之前对它多次加锁而不发生死锁。但其他线程要想获得该锁,必须等待前者执行相应次数的解锁操作。对一个已经被其他线程加锁的嵌套锁解锁,或者对一个已经解锁的嵌套锁再次解锁,则解锁操作返回EPERM

PHTREAD_MUTEX_DEFAULT:一般映射成上面三种锁之一,若不映射,一切不合理的操作都可能导致不可预期的后果。


条件变量

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

#include <pthread.h>

int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *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(pthread_cond_t *cond, pthread_mutex_t *mutex);
int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, const struct timespec *abstime);
  • 作用:
    • pthread_cond_init,初始化条件变量
    • pthread_cond_destroy,销毁条件变量,释放其占用的资源。销毁一个正在等待的条件变量将失败并返回EBUSY
    • pthread_cond_broadcast,以广播的方式唤醒所有等待目标条件变量的线程
    • pthread_cond_signal,以信号的方式唤醒所有等待目标条件变量的线程
    • pthread_cond_wait,等待目标条件变量
  • 参数:
    • cond:目标条件变量
    • attr:指定条件变量的属性
    • mutex:保护pthread_cond_wait操作的原子性
  • 返回值:
    • 成功:0
    • 失败:错误码


多线程环境

Linux中提供了上述的处理多线程编程的API,但在进行多线程编程的时候任然会出现许多问题。我们将这类问题中比较有代表性的问题汇总总结,并将其命名为多线程环境。

可重入函数:如果一个函数能被多个线程同时调用而不发生竞态条件,则我们称它是线程安全的,这个函数我们称其为可重入函数。Linux库函数中只有一小部分函数是不可重入的,比如inet_ntoa。具体不可重入库函数列表可以自行查询。Linux对很多不可重入库函数提供了可重入版本,可重入版本一般在函数名尾部加上_r。注意,在多线程程序中调用库函数,一定要用可重入版本,否则可能会导致不可预想的结果。

线程与进程:在Linux中,若一个多线程程序的某个线程调用了fork函数,那么新创建的子进程不会拥有和父进程一样数量的线程。子进程只会拥有一个执行线程的完整复制。同时,子进程会继承父进程中互斥锁的状态,这引发一个问题:子进程可能不清楚从父进程继承而来的互斥锁的具体状态。如:若互斥锁不是调用fork的那个线程锁住的,而是其他线程锁住的。那么子进程再对该互斥锁执行加锁就会导致死锁。对此,pthread提供了pthread_atfork函数来保证fork调用之后父进程和子进程都清楚锁的具体状态。该函数请读者自行搜索

线程和信号:我们知道,一个进程的所有线程共享该进程的信号,所以线程库会根据信号掩码决定将信号发给哪个具体的线程。若我们在每个线程中单独设置信号掩码,容易导致逻辑错误。又因为所有的信号共享信号处理函数,所以当一个线程设置了某个信号的处理函数后,其他所有线程的对该信号的处理函数都被修改,这并不是我们锁期望的。因此,在多线程程序中,我们通常定义一个专门的线程来处理所有的信号。

  • 26
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值