Linux应用编程基础06-多线程

145 阅读42分钟

1、线程概念

进程之间是相互独立,即使是父子进程,他们也有各自的 虚拟地址空间、映射关系、代码和数据

如果想要创建其他进程执行任务,那么虚拟地址空间、映射关系、代码和数据这几样东西是必不可少的,如果只有进程的概念,并且同时存在几百个进程,那么操作系统调度就会变得十分臃肿

为了避免这种繁琐的操作,引入了 线程

所谓线程就是:额外创建一个task_struct结构,该 task_struct 同样指向当前的虚拟地址空间,并且不需要建立映射关系及加载代码和数据,如此一来,操作系统只需要针对一个 task_struct 结构即可完成调度,成本非常低

image.png

进程的 task_struct 称为 PCB,线程的 task_struct 则称为 TCB

1.1 线程与进程的关系

线程是进程内部的一个执行流,是一个执行分支、执行粒度比进程更细、调度成本更低。

在内核的角度:

  • 进程是承担系统资源分配的基本实体:程序运行必备的:虚拟地址空间、页表映射关系、相关数据和代码 这些都是存储在进程中的
  • 线程是 CPU 运行的基本单位:程序运行时,CPU 只认 task_struct 结构,并不关心是线程还是进程,因为 CPU 只认task_struct结构,所以才说线程是 CPU 运行的基本单位

实际上进程 = PCB + TCB + 虚拟地址空间 + 映射关系 + 代码和数据

在 Linux 中,认为 PCB 与 TCB 的共同点太多,于是直接复用了 PCB 的设计思想和调度策略,在进行线程管理时,完全可以复用进程管理的解决方案(代码和结构),这可以大大减少系统调度时的开销,因此Linux中实际是没有真正的线程概念的,有的只是复用PCB设计思想的TCB,因此Linux中的线程又可以称为轻量级进程(LWP),轻量级进程 足够简单,且易于维护、效率更高、安全性更强,可以使得 Linux 系统不间断的运行程序,不会轻易崩溃

1.2 私有资源和共享资源

多线程虽然共同 “生活” 在一个进程中,但也需要有自己的 “隐私”

  1. 线程 ID:内核观点中的 LWP
  2. 一组寄存器: 线程切换时,当前线程的上下文数据需要被保存
  3. 线程独立栈: 线程在执行函数时,需要创建临时变量
  4. 错误码 errno: 线程因错误终止时,需要告知父进程
  5. 信号屏蔽字: 不同线程对于信号的屏蔽需求不同
  6. 调度优先级: 线程也是要被调度的,需要根据优先级进行合理调度

多线程还共享着进程中的部分资源,基于多线程看到的是同一块进程地址空间,理论上凡是在进程地址空间中出现的资源,多线程都是可以看到的,但实际上为了确保线程调度、运行时的独立性,只能共享部分资源

  1. 共享区、全局数据区、字符常量区、代码区: 常规资源共享区
  2. 文件描述符表: 进行 IO 操作时,无需再次打开文件
  3. 每种信号的处理方式: 多线程共同构成一个整体,信号的处理动作必须统一
  4. 当前工作目录: 即使是多线程,也是位于同一工作目录下
  5. 用户 ID 和 组 ID: 进程属于某个组中的某个用户,多线程也是如此

1.3 线程优缺点

线程 最大的优点就是 轻巧、灵活,更容易进行调度

  • 创建一个线程的代价比创建一个进程的代价要小得多
  • 调度线程比调度进程要容易得多
  • 线程占用的系统资源远小于进程
  • 可以充分利用多处理器的并行数量(进程也可以)
  • 在等待慢速 IO 操作时,程序可以执行其他任务(比如看剧软件中的 “边下边看” 功能)
  • 对于计算密集型应用,可以将计算分解到多个线程中实现(比如 压缩/解压 时涉及大量计算)
  • 对于 IO密集型应用,为了提高性能,将 IO操作重叠,线程可以同时等待资源,进行 高效IO(比如 文件/网络 的大量 IO 需要,可以通过 多路转接 技术,提高效率)

线程也是有缺点的:

  • 性能损失,当线程数量过多时,频繁的线程调度所造成的消耗会导致 计算密集型应用无法专心计算,从而造成性能损失
  • 健壮性降低,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的,一个线程的异常会导致整个进程失败。
  • 缺乏访问控制,进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响
  • 编程难度提高,编写与调试一个多线程程序需要考虑许多问题,诸如 加锁、同步、互斥 的等,面对多个执行流时,调试也是非常困难的

1.3 线程的用途

合理的使用 多线程,可以提高 CPU 计算密集型程序的效率

合理的使用 多线程,可以提高 IO 密集型程序中用户的体验(具体表现为用户可以一边下载,一边做其他事情)

2、线程控制

2.1 原生线程库

在编译多线程相关代码时,必须带上一个选项:-lpthread,否则就无法使用多线程相关接口

首先,在 Linux 中是没有真正意义上的线程的,有的只是通过进程模拟实现的线程(LWP)。为了使用户能愉快的对线程进行操作,就需要对系统提供的轻量级进程操作相关接口进行封装:对下封装轻量级进程操作相关接口,对上给用户提供线程控制的相关接口

在Linux中,封装轻量级进程操作相关接口的库称为 pthread 库,即 原生线程库

2.2 线程控制接口

创建线程

创建线程使用 pthread_create

#include <pthread.h>

int pthread_create(pthread_t *thread, 
                   const pthread_attr_t *attr,
                   void *(*start_routine) (void *), 
                   void *arg);
                   
/*
* thread:线程 ID,用于标识线程,本质上就是一个unsigned long int类型
* attr:用于设置线程的属性,比如优先级、状态、私有栈大小,一般不考虑,直接传递 nullptr 即可
* void *(*start_routine) (void *):线程启动时,会自动回调此函数
* arg:这个类型与回调函数中的参数类型匹配上,是线程运行时,传递给回调函数的参数
* 返回值:创建成功返回 0,失败返回 error number
*/

测试:

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

using namespace std;

void *threadRun(void *arg){
    while (true){
        cout << "我是次线程,我正在运行..." << endl;
        sleep(1);
    }
    return nullptr;
}

int main(){
    // 创建线程
    pthread_t t;
    pthread_create(&t, nullptr, threadRun, nullptr);

    while (true){
        cout << "我是主线程,创建了一个次线程 :" << t << endl;
        sleep(1);
    }
    return 0;
}

注意编译的时候,需要加上g++ -o exec x.cpp -lpthread

image.png

创建一批进程:

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

using namespace std;

void *threadRun(void *name){
    while (true){
        cout << "我是" << (char *)name << ",我正在运行..." << endl;
        sleep(1);
    }

    return nullptr;
}

int main(){
    // 创建3个线程
    pthread_t pt[3];
    for (int i = 0; i < 3; i++){
        char name[64];
        snprintf(name, sizeof(name), "thread-%d", i + 1);
        pthread_create(pt + i, nullptr, threadRun, name);
    }

    while (true){
        cout << "我是主线程,我正在运行..." << endl;
        sleep(1);
    }

    return 0;
}

但是次线程都打印的是thread-3

image.png

char name[64]属于主线程中栈区之上的变量,多个线程实际指向的是同一块空间,最后一次覆盖后,解决方法就是在堆区动态匹配空间,使不同的线程读取不同的空间,这样就能确保各自信息的独立性

image.png

线程等待

在程序中,如果主进程没有等次线程结束,那么这些次进程都会强制结束

主线程需要等待次线程,在原生线程库中刚好存在这样一个接口 pthread_join,用于等待次线程运行结束

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

/**
* thread:等待的线程 ID,这里的是值,不是地址和创建线程不一样
* retval:这是一个输出型参数,用于获取次线程的退出结果,如果不关心,可以传递 nullptr
* 返回值:成功返回 0,失败返回 error number
*/

测试:

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

using namespace std;

#define NUM 5

void *threadRun(void *name){
    int time = 5;
    while (time--){
        cout << "我是次线程 " << (char *)name << endl;
        sleep(1);
    }
    delete[](char *) name;
    return nullptr;
}

int main(){
    pthread_t pt[NUM];

    for (int i = 0; i < NUM; i++){
        // 注册新线程信息
        char *name = new char[64];
        snprintf(name, 64, "thread-%d", i + 1);
        pthread_create(pt + i, nullptr, threadRun, name);
    }

    // 等待次线程运行结束
    for (int i = 0; i < NUM; i++){
        int ret = pthread_join(pt[i], nullptr);// 注意第一个参数是值,不是地址
        if (ret != 0)
            cerr << "等待线程 " << pt[i] << " 失败!" << endl;
    }

    cout << "所有线程都退出了" << endl;

    return 0;
}

线程终止

线程终止方式有很多种

  1. 比如等待线程回调函数执行结束,次线程运行五秒后就结束了,然后被主线程中的 pthread_join 等待成功,次线程使命完成
  2. 在次线程回调方法中调用 exit() 函数,但这会引发一个大问题:只要其中一个线程退出了,其他线程乃至整个进程都得跟着退出,显然这不是很合理
  3. 原生线程库中有专门终止线程运行的接口 pthread_exit,专门用来细粒度地终止线程,谁调用就终止谁,不会误伤其他线程
#include <pthread.h>
void pthread_exit(void *retval);

仅有一个参数 void*:用于传递线程退出时的信息

pthread_join 中的参数2也叫 retval,这俩其实本质上是同一个东西,pthread_join 中的 void **retval 是一个输出型参数,可以把一个 void * 指针的地址传递给 pthread_join 函数,当线程调用 pthread_exit 退出时,可以根据此地址对 retval 赋值,从而起到将退出信息返回给主线程的作用

比较完善的多线程操作:

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

using namespace std;

#define NUM 5

void *threadRun(void *name){
    cout << "我是次线程 " << (char *)name << endl;
    sleep(1);
    delete[](char *) name;

    pthread_exit((void *)"EXIT");
    
    // 直接return "EXIT" 也是可以的
    // return (void*)"EXIT";
}

int main(){
    pthread_t pt[NUM];
    for (int i = 0; i < NUM; i++){
        // 注册新线程
        char *name = new char[64];
        snprintf(name, 64, "thread-%d", i + 1);
        pthread_create(pt + i, nullptr, threadRun, name);
    }

    // 等待次线程运行结束
    void *retval = nullptr; // 获取线程退出的信息
    for (int i = 0; i < NUM; i++){
        int ret = pthread_join(pt[i], &retval);
        if (ret != 0)
            cerr << "等待线程 " << pt[i] << " 失败!" << endl;
        cout << "线程 " << pt[i] << " 等待成功,退出信息是 " << (const char *)retval << endl;
    }

    cout << "所有线程都退出了" << endl;

    return 0;
}

线程关闭

可以使用 pthread_cancel 关闭已经创建并运行中的线程

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

测试:

#include <iostream>
#include <string>
#include <ctime>
#include <unistd.h>
#include <pthread.h>

using namespace std;

void *threadRun(void *arg){

    while (true){
        cout << "线程 " << (char*)arg << " 正在运行" << endl;
        sleep(1);
    }
    pthread_exit((void *)10); // 预期 retval 的值为10
}

int main()
{
    const char *name = "Thread1";
    pthread_t t;
    pthread_create(&t, nullptr, threadRun, (void *)name);

    // 3秒后关闭线程
    sleep(3);

    pthread_cancel(t);

    void *retval = nullptr;
    pthread_join(t, &retval);

    // 使用 int64_t 结果是无符号的-1不好看
    cout << "线程 " << t << " 已退出,退出信息为 " << (int64_t)retval << endl;
    
    return 0;
}

退出信息为 -1,与预设的 10 不相符,只要是被 pthread_cancel 关闭的线程,退出信息统一为 PTHREAD_CANCELED 即 -1

获取线程ID

线程 ID 是线程的唯一标识符,可以通过 pthread_self 获取当前线程的 ID

#include <pthread.h>

pthread_t pthread_self(void);

线程分离

父进程需要阻塞式等待子进程退出,主线程等该次线程时也是阻塞式等待,父进程可以设置为 WNOHANG,变成轮询式等待,避免自己一直处于阻塞

一般情况下,线程终止后,其终止状态一直保留到其它线程调用pthread_join获取它的状态为止。但是线程也可以被置为detach状态,这样的线程一旦终止就立刻回收它占用的所有资源,而不保留终止状态。

线程在被创建时,默认属性都是 joinable 的,即主线程需要使用 pthread_join 来等待次线程退出,并对其进行资源释放;实际上可以把这一操作留给系统自动处理,如此一来主线程就可以不必等待次线程,也就可以避免等待时阻塞了,这一操作叫做线程分离

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

线程分离的本质是将 joinable 属性修改为 detach,告诉系统线程退出后资源自动释放

注意: 如果线程失去了 joinable 属性,就无法被 join,如果 join 就会报错

#include <iostream>
#include <string>
#include <ctime>
#include <unistd.h>
#include <pthread.h>

using namespace std;

void *threadRun(void *arg){
    int n = 3;
    while(n){
        cout << "次线程 " << n-- << endl;
        sleep(1);
    }
}

int main(){
    pthread_t t;
    pthread_create(&t, nullptr, threadRun, nullptr);

    pthread_detach(t);

    int n = 5;
    while(n){
        cout << "主线程 " << n-- << endl;
        sleep(1);
    }
    return 0;
}

线程清理处理函数

与进程不同,一个线程可以注册多个清理函数,这些清理函数记录在栈中,每个线程都可以拥有一个清 理函数栈(执行顺序与注册顺序相反)当执行完所有清理函数后,线程终止

pthread_cleanup_push()pthread_cleanup_pop()分别负责向调用线程的清理函数栈中添加 和移除清理函数

#include <pthread.h>

void pthread_cleanup_push(void (*routine)(void *), void *arg);
void pthread_cleanup_pop(int execute);

/*
* routine:是一个函数指针,指向一个需要添加的清理函数,routine()函数无返回值,只有一个 void *类型参数
* arg:当调用清理函数 routine()时,将 arg 作为 routine()函数的参数
* execute:是否在弹出清理函数的同时执行该函数,为0表示不执行,非0为执行
*/

测试:

#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;

void cleanup(void *arg)
{
    cout << "cleanup: " << (char *)arg << endl;
}
void *new_thread_start(void *arg)
{
    pthread_cleanup_push(cleanup, (char *)"1th");
    pthread_cleanup_push(cleanup, (char *)"2th");
    pthread_cleanup_push(cleanup, (char *)"3th");
    sleep(2);
    pthread_exit((void *)0); //线程终止
    /* 为了与 pthread_cleanup_push 配对,不添加程序编译不通过 */
    pthread_cleanup_pop(0);
    pthread_cleanup_pop(0);
    pthread_cleanup_pop(0);
}
int main(void)
{
    pthread_t tid;
    pthread_create(&tid, NULL, new_thread_start, NULL);
    pthread_join(tid, nullptr);
    exit(0);
}

image.png

2.3 理解线程

原生线程库

原生线程库本质上也是一个文件,是一个存储在 /lib 目录下的动态库

程序运行时,原生线程库需要从磁盘加载至内存中,再通过进程地址空间映射至共享区中供线程使用

image.png

由于用户并不会直接使用轻量级进程的接口,于是需要借助第三方库进行封装,在线程库中创建TCB结构(类似于 PCB),其中存储线程各种信息,比如线程独立栈信息等

在内存中,整个线程库就像一个 “数组”,其中的一块块空间聚合排布TCB信息,而每个TCB的起始地址就表示当前线程的ID(pthread_t类型),地址是唯一的,因此线程 ID 也是唯一的

image.png

即便是 C++11 提供的thread线程库,在Linux平台中运行时,也需要带上-lpthread选项,因为它本质上是对 原生线程库的封装

线程独立栈

线程之间存在独立栈,可以保证彼此之间执行任务时不会相互干扰

#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>

using namespace std;

string toHex(pthread_t t){
    char id[64];
    snprintf(id, sizeof(id), "0x%x", t);
    return id;
}

void *threadRun(void *arg){
    int tmp = 0;
    cout << "thread " << toHex(pthread_self()) << " &tmp: " << &tmp << endl;

    return (void*)0;
}

int main(){
    pthread_t t[5];
    for(int i = 0; i < 5; i++){
        pthread_create(t + i, nullptr, threadRun, nullptr);
        sleep(1);
    }

    for(int i = 0; i < 5; i++)
        pthread_join(t[i], nullptr);
    return 0;
}

image.png

存在这么多栈结构,CPU 在运行时是如何区分的呢

答案是 通过 栈顶指针 ebp 和 栈底指针 esp 进行切换ebp 和 esp 是 CPU 中两个非常重要的 寄存器,即便是程序启动,也需要借助这两个 寄存器 为 main 函数开辟对应的 栈区

image.png

注意:

  • 所有线程都要有自己独立的栈结构(独立栈),主线程中用的是进程系统栈,次线程用的是库中提供的栈
  • 多个线程调用同一个入口函数(回调方法),其中的局部变量地址一定不一样,因为存储在线程独立栈中

线程局部存储

线程之间共享全局变量

可以给全局变量加 __thread 修饰,修饰之后,全局变量不再存储至全局数据区,而且存储至线程的 局部存储区中

#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>

using namespace std;

int val1 = 1;
__thread int val2 = 2;

void *threadRun(void *arg)
{
    int tmp = 0;
    cout << "val1:" << &val1 << endl;
    cout << "val2:" << &val2 << endl;
    cout << "tmp:" << &tmp << endl;
    return (void *)0;
}

int main()
{
    pthread_t pt;
    pthread_create(&pt, nullptr, threadRun, nullptr);

    pthread_join(pt, nullptr);
    return 0;
}

image.png

image.png

3、线程互斥

3.1 资源共享问题

两个进程,同时对全局变量 val 进行++操作

image.png

但是每次执行的结果都不一样:

image.png

val++操作需要三步:

  1. 先将 val 的值拷贝至寄存器中
  2. 在 CPU 内部通过运算寄存器完成计算
  3. 将寄存器中的值拷贝回内存

这三步操作不是原子操作,如果thread_B在thread_A执行完第一步执行第二步前,执行第一步,那么获取到val的值是修改前的值,那么等两个进程执行完后,val的值也只会加一次,这就出现了问题

临界区与临界资源

在多线程场景中,对于诸如 val 这种可以被多线程看到的同一份资源称为临界资源

涉及对临界资源进行操作的上下文代码区域称为临界区

image.png

加锁

对于 临界资源 访问时的安全问题,也可以通过 加锁 来保证,实现多线程间的 互斥访问互斥锁 就是解决多线程并发访问问题的手段之一

在进入临界区之前加锁,出临界区之后解锁, 这样可以确保并发访问临界资源时的绝对串行化,比如之前的 thread_A 和 thread_B 在并发访问 val 时,如果进行了 加锁,在 thread_A 被切走后,thread_B 无法对 val 进行操作,因为此时锁被 thread_A 持有,thread_B 只能阻塞式等待锁,直到 thread_A 解锁

  • 加锁、解锁是比较耗费系统资源的,会在一定程序上降低程序的运行速度
  • 加锁后的代码是串行化执行的,势必会影响多线程场景中的运行速度
  • 所以为了尽可能的降低影响,加锁粒度要尽可能的细

3.2 线程互斥

互斥:事件 A 与事件 B 不会同时发生

3.2.1 互斥锁相关操作

互斥锁同样出自原生线程库,类型为pthread_mutex_t

创建和销毁互斥锁

互斥锁是一种向系统申请的资源,在使用完毕后需要销毁

#include <pthread.h>

// 创建
int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);
// 销毁
int pthread_mutex_destroy(pthread_mutex_t *mutex);

示例代码:

#include <iostream>
#include <pthread.h>
using namespace std;

int main()
{
    pthread_mutex_t mtx; //定义互斥锁
    pthread_mutex_init(&mtx, nullptr); // 初始化互斥锁

    // ...

    pthread_mutex_destroy(&mtx); // 销毁互斥锁
    return 0;
}

使用 pthread_mutex_init 初始化 互斥锁 的方式称为 动态分配,需要手动初始化和销毁

除此之外还存在 静态分配,即在定义 互斥锁 时初始化为 PTHREAD_MUTEX_INITIALIZER

pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;

静态分配的优点在于无需手动初始化和手动销毁,锁的生命周期伴随程序,缺点就是定义的互斥锁 必须为全局互斥锁

加锁操作

使用 pthread_mutex_lock 进行加锁

#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);

加锁时,会遇到两种情况:

  • 当前互斥锁没有被别人持有,正常加锁,函数返回 0
  • 当前互斥锁被别人持有,加锁失败,当前线程被阻塞(执行流被挂起),无法向后运行,直到获得锁资源
解锁操作

使用pthread_mutex_unlock进行解锁

#include <pthread.h>
int pthread_mutex_unlock(pthread_mutex_t *mutex);

在加锁成功并完成对临界资源的访问后,就应该进行解锁,将 锁资源 让出,供其他线程(执行流)进行加锁

如果不进行解锁操作,会导致后续线程无法申请到锁资源而永久等待,引发死锁问题

3.2.2 解决抢票问题

五个线程一起抢票

#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>

using namespace std;

class TData // 为了让多个线程看到同一把锁,把锁放在对象中传递给线程
{
public:
    TData(const string &name, pthread_mutex_t *pmtx): _name(name), _pmtx(pmtx){}

public:
    string _name;           // 进程名
    pthread_mutex_t *_pmtx; // 锁
};

int tickets = 1000; // 票数:临界资源

void *threadRoutine(void *args){
    int num = 0;
    TData *td = static_cast<TData *>(args);

    while (1){
        pthread_mutex_lock(td->_pmtx); // 加锁
        if (tickets > 0){
            tickets--;
            num++;
            usleep(200); 
            pthread_mutex_unlock(td->_pmtx);  // 解锁
        }
        else{
            pthread_mutex_unlock(td->_pmtx); // 解锁
            break;
        }
        usleep(200); 
    }

    // 屏幕也是共享资源,加锁可以有效防止打印结果错行
    pthread_mutex_lock(td->_pmtx);
    cout << td->_name << "抢票完毕,抢到票数:" << num << endl;
    pthread_mutex_unlock(td->_pmtx);

    delete td;
    return nullptr;
}

int main()
{

    pthread_t pt[5];
    pthread_mutex_t mtx; // 定义互斥锁

    pthread_mutex_init(&mtx, nullptr); // 初始化互斥锁

    for (int i = 0; i < 5; i++){
        char *name = new char[16];
        snprintf(name, 16, "thread-%d", i + 1);
        TData *td = new TData(name, &mtx);
        pthread_create(pt + i, nullptr, threadRoutine, td);
    }

    for (int i = 0; i < 5; i++)
        pthread_join(pt[i], nullptr);

    pthread_mutex_destroy(&mtx); // 销毁互斥锁
    cout << "抢票结束,还剩:" << tickets << "张票!" << endl;
    return 0;
}

image.png

如果不加锁,最后剩余的票数可能为负数

3.3 互斥锁的原理

大多数 CPU 的体系结构(比如 ARM、X86、AMD 等)都提供了 swap 或者 exchange 指令,这种指令可以把寄存器和内存单元的数据直接交换,由于这种指令只有一条语句,可以保证指令执行时的原子性

加锁

一段伪汇编代码(加锁相关的),本质就是pthread_mutex_lock()

lock:
	movb $0, %al   // 其中 movb 表示赋值,al 为一个寄存器
	xchgb %al, mutex  // xchgb 就是支持原子操作的 exchange 交换语句,mutex是内存中的值
	if(al寄存器里的内容 > 0){
		return 0;
	} else
		挂起等待;
	goto lock;

注意:计算机中的硬件,如 CPU 中的寄存器只有一份,被所有线程共享,但其中的内容随线程,不同线程的内容可能不同,也就是我们常说的上下文数据

寄存器 != 寄存器中的内容(执行流的上下文)

1、当线程 thread_A 首次加锁时:

  • 首先,将 0 赋值给 al 寄存器,这里假设 mutex 默认值为 1(其他非 0 整数也行)
  • 然后,将 al 寄存器中的值与 mutex 的值交换(原子操作)
  • 判断当前 al 寄存器中的值是否 > 0,此时线程 thread_A 就可以访问 临界区 代码了,如果此时线程 thread_A 被切走了(并没有出临界区,锁资源也没有释放),OS 会保存 thread_A 的上下文数据,并让线程 thread_B 入场

2、thread_B 试图进入临界区

  • 首先将 al 寄存器中的值赋为 0 (thread_B的ai寄存器的值和thread_A不同)
  • 将 al 寄存器中的值与 mutex 的值交换(mutex是内存中的值被所有线程共享,因此 thread_B 看到的 mutex 是被 thread_A 修改后的值=0,两个都为0交换了个寂寞)
  • 最后判断thread_B的 al 寄存器中的值是否 > 0,此时的 因为没有 锁资源 而被拒绝进入 临界区(此时 thread_A 的上下文数据中,al = 1 是解开临界区的钥匙)

汇编代码中 xchgb %al, mutex 的本质就是 加锁,当mutex不为0时,表示钥匙可用,可以进行 加锁;并且因为 xchgb %al, mutex 只有一条汇编指令,足以确保 加锁 过程是 原子性 的

image.png

解锁

本质上就是执行 pthread_mutex_unlock() 函数

unlock:
	movb $1, mutex  // 将 mutex 中的值赋为 1
	唤醒等待 [锁资源] 的线程; // 让 thread_B 进来
	return

image.png

3.4 互斥锁细节

  1. 凡是访问同一个临界资源的线程,都要进行加锁保护,而且必须加同一把锁,这是规则必须遵守
  2. 每一个线程访问临界区资源之前都要加锁,本质上是给临界区加锁
  3. 加锁是为了保护临界资源的安全,但锁本身也是临界资源,加锁和解锁操作都是原子的,不存在中间状态,也就不需要保护了
  4. 临界区本身是一行代码,或者一批代码

3.5 线程安全

3.5.1 可重入函数

重入同一个函数被多个线程(执行流)调用,当前一个执行流还没有执行完函数时,其他执行流可以进入该函数,这种行为称之为重入;在发生重入时,函数运行结果不会出现问题,称该函数为 可重入函数,否则称为 不可重入函数

常见不可重入的情况:

  • 调用了 malloc / free 函数,因为这些都是 C语言 提供的接口,通过全局链表进行管理
  • 调用了标准 I/O 库函数,其中很多实现都是以不可重入的方式来使用全局数据结构
  • 可重入函数体内使用了静态的数据结构

常见可重入的情况

  • 不使用全局变量或静态变量
  • 不使用 malloc 或 new 开辟空间
  • 不调用不可重入函数
  • 不返回全局或静态数据,所有的数据都由函数调用者提供
  • 使用本地数据或者通过制作全局数据的本地拷贝来保护全局数据

3.5.2 线程安全函数

线程安全:多线程并发访问同一段代码时,不会出现不同的结果,此时就是线程安全的;但如果在没有加锁保护的情况下访问全局变量或静态变量,导致出现不同的结果,此时线程就是不安全的

常见线程不安全的情况:

  • 不保护共享变量,比如全局变量和静态变量
  • 函数的状态随着被调用,而导致状态发生变化
  • 返回指向静态变量指针的函数
  • 调用线程不安全函数的函数

常见线程安全的情况:

  • 每个线程对全局变量或静态变量只有读取权限,而没有写入权限,一般来说都是线程安全的
  • 类或者接口对于线程来说都是原子操作
  • 多个线程之间的切换不会导致执行结果存在二义性

譬如下面这个函数是一个不可重入函数,同样也是一个线程不安全函数

static int glob = 0;
static void func(int loops)
{
    int local;
    int j;
    for (j = 0; j < loops; j++)
    {
        local = glob;
        local++;
        glob = local;
    }
}

如果对该函数进行修改,使用互斥锁对共享变量 glob 的访问进行保护,在读写该变量之前先上锁、读写完成之后在解锁。这样,该函数就变成了一个线程安全函数,但是它依然不是可重入函数,因为该函数更改了外部全局变量的值。

联系与区别

重入与线程安全的联系:

  • 如果函数是可重入的,那么函数就是线程安全的;不可重入的函数有可能引发线程安全问题
  • 如果一个函数中使用了全局数据,那么这个函数既不是线程安全的,也不是可重入的

重入与线程安全的区别

  • 可重入函数是线程安全函数的一种
  • 线程安全不一定是可重入的,反过来可重入函数一定是线程安全的
  • 如果对于临界资源的访问加上锁,则这个函数是线程安全的;但如果这个重入函数中没有被释放会引发死锁,因此是不可被重入的

如果想确认某个函数是不是线程安全函数可以通过 man 手册可以查看库函数的 ATTRIBUTES 信息,如果函数被标记为 MT-Safe,则表示该函数是一个线程安全函数,如果被标记为 MT-Unsafe,则意味着该函数是一个非线程安全函数

3.5.3 一次性初始化

在多线程编程环境下,有些代码段只需要执行一次,譬如一些初始化相关的代码段

有一个函数 func(),该函数可能会被多个线程调用,并且该函数中有一段初始化代码,该段代码只能被执行一次(无论哪个线程执行都可以)、如果执行多次会出现问题

static void func(void)
{
    /* 只能执行一次的代码段 */
    init_once();
    ..........
}

使用pthread_once就能实现上面的需求

#include <pthread.h>
pthread_once_t once_control = PTHREAD_ONCE_INIT;

int pthread_once(pthread_once_t *once_control, void (*init_routine)(void));

/*
* once_control:这是一个 pthread_once_t 类型指针,使用 PTHREAD_ONCE_INIT 宏对其进行初始化
* init_routine:一个函数指针,指向的函数就是要求只能被执行一次的代码段
* 返回值:调用成功返回 0
*/

尽管 pthread_once()调用会出现在多个线程中,但该函数会保证 init_routine()函数仅执行一次,究竟在哪个线程中执行是不定的,是由内核调度来决定

测试:

#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;

pthread_once_t once = PTHREAD_ONCE_INIT;

void initializeOnce(void)
{
    printf("initializeOnce 被执行: 线程 ID<%lu>\n", pthread_self());
}
void func(void)
{
    pthread_once(&once, initializeOnce); //执行一次性初始化函数
    printf("函数 func 执行完毕.\n");
}
void *threadRun(void *arg)
{
    cout << "线程" << (char *)arg << "被创建: 线程 ID<" << pthread_self() << ">" << endl;
    func();             //调用函数 func
    pthread_exit(NULL); //线程终止
}

int main(void)
{
    pthread_t tid[5];
    /* 创建 5 个线程 */
    int i;
    for (i = 0; i < 5; i++)
    {
        char *name = new char[16];
        snprintf(name, 16, "thread-%d", i + 1);
        pthread_create(&tid[i], NULL, threadRun, (void *)name);
    }

    for (i = 0; i < 5; i++)
        pthread_join(tid[i], NULL);
}

image.png

3.5.4 线程局部存储

Linux对局部存储的支持

为每个调用线程分别维护一份变量的副本,每个线程通过特有数据键key访问时,这个特有数据键都会获取到本线程绑定的变量副本,可以避免变量成为多个线程间的共享数据

在Linux下主要涉及到 3 个函数:pthread_key_create()pthread_setspecific()以及 pthread_getspecific()

pthread_key_create: 为线程分配私有数据区之前,需要调用 pthread_key_create()函数创建一个特有数据键(key),并且 只需要在首个调用的线程中创建一次即可

#include <pthread.h>
int pthread_key_create(pthread_key_t *key, void (*destructor)(void*));

/*
* key:调用该函数会创建一个特有数据键,并通过参数 key 所指向的缓冲区返回给调用者,调用前,需要
*      定义一个 pthread_key_t 类型变量,调用 pthread_key_create()时参数 key 指向 pthread_key_t 类型变量
* destructor:函数指针,指向一个自定义的函数(析构函数),用于释放与特有数据键关联的线程私有数据区占用的内存空间

pthread_setspecific: 首先保存指向线程私有数据缓冲区的指针,并将其与特有数据键以及当前调用线程关联起来

#include <pthread.h>
int pthread_setspecific(pthread_key_t key, const void *value);

/*
* key: 就是 pthread_key_create()函数的参数 key 所指向的 pthread_key_t 变量
* value: void 类型的指针,指向由调用者分配的一块内存,作为线程的私有数据缓冲区,当线程终止时
*        会自动调用参数 key 指定的特有数据键对应的解构函数来释放这一块动态申请的内存空间

pthread_getspecific: 调用 pthread_setspecific()函数将线程私有数据缓冲区与调用线程以及特有数据键关联之后,便可以使用 pthread_getspecific()函数来获取调用线程的私有数据区

#include <pthread.h>
void *pthread_getspecific(pthread_key_t key);

/*
* key: pthread_key_create()函数的参数 key 所指向的 pthread_key_t 变量
* 返回值:返回当前调用线程关联到特有数据键的私有数据缓冲区,返回值是一个指针,指向该缓冲区。如果当前调用线程并没
*     有设置线程私有数据缓冲区与特有数据键进行关联,则返回值应为NULL,函数中可以利用这一点来判断当前调用线程是
*     否为初次调用该函数,如果是初次调用,则必须为该线程分配私有数据缓冲区
*/

pthread_key_delete:除了以上介绍的三个函数外,如果需要删除一个特有数据键(key)可以使用函数 pthread_key_delete(),pthread_key_delete()函数删除先前由 pthread_key_create()创建的键

#include <pthread.h>
int pthread_key_delete(pthread_key_t key);

调用 pthread_key_delete()时,它并不检查当前是否有线程正在使用该键所关联的线程私有数据缓冲区,所以它并不会触发键的解构函数,也就不会释放键关联的线程私有数据区占用的内存资源,并且调用pthread_key_delete()后,当线程终止时也不再执行键的解构函数。所以,通常在调用 pthread_key_delete()之前,必须确保以下条件:

  • 所有线程已经释放了私有数据区(显式调用解构函数或线程终止)
  • 参数 key 指定的特有数据键将不再使用

以下是strerror()函数以非线程安全方式实现的一种写法

#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#define MAX_ERROR_LEN 256
static char buf[MAX_ERROR_LEN];
static char *strerror(int errnum)
{
    if (errnum < 0 || errnum >= _sys_nerr || NULL == _sys_errlist[errnum])
        snprintf(buf, MAX_ERROR_LEN, "Unknown error %d", errnum);
    else
    {
        strncpy(buf, _sys_errlist[errnum], MAX_ERROR_LEN - 1);
        buf[MAX_ERROR_LEN - 1] = '\0'; //终止字符
    }
    return buf;
}

可以看到该函数返回的字符串指针,其实是一个静态数组,当多个线程同时调用该函数时,那么 buf 缓冲区中的数据将会出现混乱,因为前一个调用线程拷贝到 buf 中的数据可能会被后一个调用线程重写覆盖等情况

多线程的情况下调用它:

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

#define MAX_ERROR_LEN 256
char buf[MAX_ERROR_LEN];

// 为了避免与库函数 strerror 重名,这里将其改成 my_strerror
char *my_strerror(int errnum)
{
    if (errnum < 0 || errnum >= _sys_nerr || NULL == _sys_errlist[errnum])
        snprintf(buf, MAX_ERROR_LEN, "Unknown error %d", errnum);
    else
    {
        strncpy(buf, _sys_errlist[errnum], MAX_ERROR_LEN - 1);
        buf[MAX_ERROR_LEN - 1] = '\0'; //终止字符
    }
    return buf;
}
void *threadRun(void *arg)
{
    char *str = my_strerror(2); //获取错误编号为 2 的错误描述信息
    printf("子线程: str (%p) = %s\n", str, str);
    pthread_exit(NULL);
}
int main(int argc, char *argv[])
{
    pthread_t tid;
    char *str = NULL;
    int ret;
    str = my_strerror(1); //获取错误编号为 1 的错误描述信息
    /* 创建子线程 */
    if (ret = pthread_create(&tid, NULL, threadRun, NULL))
    {
        perror("pthread_create");
        exit(-1);
    }
    /* 等待回收子线程 */
    if (ret = pthread_join(tid, NULL))
    {
        perror("pthread_join");
        exit(-1);
    }
    printf("主线程: str (%p) = %s\n", str, str);
    exit(0);
}

主线程和子线程打印的错误描述信息是不一样的,因为错误编号不同,但上面的测试结果证实它们打印的结果是相同的 image.png 字符串指针指向的是同一个缓冲区;原因就在于,my_strerror()函数是一个非线程安全函数,函数内部修改了全局静态变量、并返回了它的指针,每一次调用访问的都是同一个静态变量,所以后一次调用会覆盖掉前一次调用的结果

接下来线程特有数据技术对strerror()非线程安全的代码进行修改:

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

#define MAX_ERROR_LEN 256

pthread_once_t once = PTHREAD_ONCE_INIT;
pthread_key_t strerror_key;

void destructor(void *buf)
{
    free(buf); //释放内存
}
void create_key(void)
{
    // 创建一个键(key),并且绑定键的解构函数
    // 通过key指向新创建的键缓冲区
    if (pthread_key_create(&strerror_key, destructor))
        pthread_exit(NULL);
}
char *my_strerror(int errnum)
{
    char *buf;
    // 创建一个键(只执行一次 create_key)
    if (pthread_once(&once, create_key))
        pthread_exit(NULL);

    // 获取,如果是第一次获取则返回NULL
    buf = (char *)pthread_getspecific(strerror_key);
    if (NULL == buf){ //首次调用 my_strerror 函数,需给调用线程分配线程私有数据
        buf = (char *)malloc(MAX_ERROR_LEN); //分配内存
        if (NULL == buf)
            pthread_exit(NULL);
        // 保存缓冲区地址,与键、线程关联起来
        if (pthread_setspecific(strerror_key, buf))
            pthread_exit(NULL);
    }

    if (errnum < 0 || errnum >= _sys_nerr || NULL == _sys_errlist[errnum])
        snprintf(buf, MAX_ERROR_LEN, "Unknown error %d", errnum);
    else
    {
        strncpy(buf, _sys_errlist[errnum], MAX_ERROR_LEN - 1);
        buf[MAX_ERROR_LEN - 1] = '\0'; 
    }

    return buf;
}
void *threadRun(void *arg)
{
    char *str = my_strerror(2); //获取错误编号为 2 的错误描述信息
    printf("子线程: str (%p) = %s\n", str, str);
    pthread_exit(NULL);
}
int main(int argc, char *argv[])
{
    pthread_t tid;
    char *str = NULL;
    int ret;
    str = my_strerror(1); //获取错误编号为 1 的错误描述信息
    /* 创建子线程 */
    if (ret = pthread_create(&tid, NULL, threadRun, NULL))
    {
        perror("pthread_create");
        exit(-1);
    }
    /* 等待回收子线程 */
    if (ret = pthread_join(tid, NULL))
    {
        perror("pthread_join");
        exit(-1);
    }
    printf("主线程: str (%p) = %s\n", str, str);
    exit(0);
}
语言层面对线程局部存储的支持

线程局部存储在定义全局或静态变量时,使用 __thread 修饰符修饰变量,此时,每个线程都会拥有一份对该 变量的拷贝。线程局部存储中的变量将一直存在,直至线程终止,届时会自动释放这一存储

static __thread char buf[512];

但凡带有这种修饰符的变量,每个线程都拥有一份对变量的拷贝,意味着每个线程访问的都是该变量在本线程的副本,从而避免了全局变量成为多个线程的共享数据。

对strerror进行修改

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

#define MAX_ERROR_LEN 256

__thread char buf[MAX_ERROR_LEN]; // 每个线程都会拥有buf的拷贝

char *my_strerror(int errnum)
{
    if (errnum < 0 || errnum >= _sys_nerr || NULL == _sys_errlist[errnum])
        snprintf(buf, MAX_ERROR_LEN, "Unknown error %d", errnum);
    else
    {
        strncpy(buf, _sys_errlist[errnum], MAX_ERROR_LEN - 1);
        buf[MAX_ERROR_LEN - 1] = '\0'; 
    }

    return buf;
}
void *threadRun(void *arg)
{
    char *str = my_strerror(2); //获取错误编号为 2 的错误描述信息
    printf("子线程: str (%p) = %s\n", str, str);
    pthread_exit(NULL);
}
int main(int argc, char *argv[])
{
    pthread_t tid;
    char *str = NULL;
    int ret;
    str = my_strerror(1); //获取错误编号为 1 的错误描述信息
    /* 创建子线程 */
    if (ret = pthread_create(&tid, NULL, threadRun, NULL))
    {
        perror("pthread_create");
        exit(-1);
    }
    if (ret = pthread_join(tid, NULL))
    {
        perror("pthread_join");
        exit(-1);
    }
    printf("主线程: str (%p) = %s\n", str, str);
    exit(0);
}

3.6 死锁

死锁:指在一组进程中的各个线程均占有不会释放的资源,但因相互申请被其他线程所占用不会释放的资源处于一种永久等待状态,多个线程都因锁资源的等待而被同时挂起,导致程序陷入死循环

死锁产生的四个必要条件

  1. 互斥:一个资源每次只能被一个执行流使用
  2. 请求与保持:一个执行流因请求资源而阻塞时,对已获得的资源保持不释放
  3. 环路等待:若干执行流之间形成一种首尾相接的循环等待资源关系
  4. 不剥夺条件:不能强行剥夺其他线程的资源

只有一把锁会造成死锁吗?

会的,如果线程 thread_A 申请锁资源,访问完临界资源后没有释放,会导致线程 thread_B 无法申请到锁资源,同时线程 thread_A 自己也申请不到锁资源了

避免死锁

核心思想:破坏四个必要条件的其中一个或多个

方法1:不加锁

不加锁的本质是不保证 互斥,即破坏条件1

方法2:尝试主动释放锁

比如进入临界区访问临界资源,需要两把锁,thread_A 和 thread_B 各自持有一把锁,并且都在尝试申请第二把锁,但如果此时 thread_A 放弃申请,主动把锁释放,这样就能打破死锁的局面

可以借助 pthread_mutex_trylock() 函数实现

#include <pthread.h>
int pthread_mutex_trylock(pthread_mutex_t *mutex);

当互斥锁已经被其它线程锁住时,调用pthread_mutex_lock()函数会被阻塞,直到互斥锁解锁;如果线程不希望被阻塞,可以使用pthread_mutex_trylock()函数,调用pthread_mutex_ trylock()函数尝试对互斥锁进行加锁,如果互斥锁处于未锁住状态,那么调用pthread_mutex _trylock()将会锁住互斥锁并立马返回,如果互斥锁已经被其它线程锁住,调用pthread_mutex _trylock()加锁失败,但不会阻塞,而是返回错误码 EBUSY

如果长时间申请不到锁,就会把自己当前持有的锁释放,然后放弃加锁,给其他想要加锁的线程一个机会

方法3:按照顺序申请锁

按照顺序申请锁 -> 按照顺序释放锁 -> 就不会出现环路等待的情况

方法4:控制线程统一释放锁

首先要明白:锁不一定要由申请锁的线程释放,其他线程也可以释放锁

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

using namespace std;

// 全局互斥锁,无需手动初始化和销毁
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;

void* threadRoutine(void* args) {
	cout << "我是次线程,我开始运行了" << endl;

	// 申请锁
	pthread_mutex_lock(&mtx);
	cout << "我是次线程,我申请到了一把锁" << endl;

	// 在不释放锁的情况下,再次申请锁,陷入 死锁 状态
	pthread_mutex_lock(&mtx);
	cout << "我是次线程,我又再次申请到了一把锁" << endl;

	pthread_mutex_unlock(&mtx);

	return nullptr;
}

int main() {
	pthread_t t;
	pthread_create(&t, nullptr, threadRoutine, nullptr);

	// 等待次线程先跑
	sleep(3);

	// 主线程帮忙释放锁
	pthread_mutex_unlock(&mtx);
	cout << "我是主线程,我已经帮次线程释放了一把锁" << endl;

	// 等待次线程后续动作
	sleep(3);

	pthread_join(t, nullptr);
	cout << "线程等待成功" << endl;
	return 0;
}

image.png

因此,可以设计一个 控制线程,专门掌管所有的锁资源,如果识别到发生了 死锁 问题,就释放所有的锁,让线程重新竞争

4.线程同步

同步:在保证数据安全的前提下(互斥),让线程能够按照某种特定顺序访问临界资源

线程互斥:只能让多个线程之间不会同时访问临界区,但是线程的访问顺序无法确定

线程同步的几种不同的方法,包括互斥锁、条件变量、自旋锁以及读写锁,当然,除此之外, 线程同步的方法其实还有很多,譬如信号量、屏障等

4.1 条件变量

原生线程库中提供了条件变量这种方式来实现线程同步

条件变量:当一个线程互斥的访问某个变量时,它可能发现在其他线程改变状态之前,什么也不做。比如当一个线程访问队列时,发现队列为空,它只能等待,直到其他线程往队列中添加数据。条件变量的本质就是,衡量访问资源的状态

可以把条件变量看作一个结构体,其中包含一个队列结构(队列是保证顺序性的重要工具),用来存储正在排队等候的线程信息,当条件满足时,就会取队头线程进行操作,操作完成后重新进入队尾

image.png

条件变量创建与销毁

使用接口与互斥锁风格差不多

#include <pthread.h>
// 定义一个条件变量
pthread_cond_t cond; 

// 创建
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
// 销毁
int pthread_cond_destroy(pthread_cond_t *cond);

/**
* 参数1 pthread_cond_t* 表示想要初始化的条件变量
* 参数2 const pthread_condattr_t* 表示初始化时的相关属性,设置为 nullptr 表示使用默认属性
*/

注:同互斥锁一样,条件变量支持静态分配,即在创建全局条件变量时,定义为 PTHREAD_COND_INITIALIZER,表示自动初始化、自动销毁

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

注意: 这种定义方式只支持全局条件变量

条件等待

原生线程库 中提供了 pthread_cond_wait 函数用于等待

#include <pthread.h>

int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);

/**
* 参数1 pthread_cond_t* 想要加入等待的条件变量
* 参数2 pthread_mutex_t* 互斥锁,用于辅助条件变量
*/

传递互斥锁的理由:

  1. 条件变量也是临界资源,需要保护
  2. 当条件不满足时(没有被唤醒),当前持有锁的线程就会被挂起,其他线程还在等待锁资源,为了避免死锁问题,条件变量需要具备自动释放锁的能力

当某个线程被唤醒时,条件变量释放锁,该线程会获取锁资源,并进入 条件等待 状态

唤醒线程

条件变量 中的线程是需要被唤醒的,否则它也不知道何时对 队头线程 进行判断,可以使用 pthread_cond_signal 函数进行唤醒(只能唤醒一个,即队头线程)

#include <pthread.h>

int pthread_cond_signal(pthread_cond_t *cond);

如果想唤醒全部线程,可以使用 pthread_cond_broadcast

#include <pthread.h>

int pthread_cond_broadcast(pthread_cond_t *cond);

简单同步 Demo

创建 5 个次线程,等待条件满足,主线程负责唤醒

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

using namespace std;

// 互斥锁和条件变量都定义为自动初始化和释放
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

const int num = 5; // 创建五个线程

void* Active(void* args)
{
    const char* name = static_cast<const char*>(args);

    while(true)
    {
        // 加锁
        pthread_mutex_lock(&mtx);

        // 等待条件满足
        pthread_cond_wait(&cond, &mtx);
        cout << "\t线程 " << name << " 正在运行" << endl;

        // 解锁
        pthread_mutex_unlock(&mtx);
    }

	delete[] name;
    return nullptr;
}

int main()
{
    pthread_t pt[num];
    for(int i = 0; i < num; i++)
    {
        char* name = new char[32];
        snprintf(name, 32, "thread-%d", i);
        pthread_create(pt + i, nullptr, Active, name);
    }

    // 等待所有次线程就位
    sleep(3);

    // 主线程唤醒次线程
    while(true)
    {
        cout << "Main thread wake up Other thread!" << endl;
        pthread_cond_signal(&cond); // 单个唤醒
        sleep(1);
    }

    for(int i = 0; i < num; i++)
        pthread_join(pt[i], nullptr);

    return 0;
}

image.png

互斥锁+条件变量 可以实现 生产者消费者模型

4.2 读写锁

互斥锁或自旋锁要么是加锁状态、要么是不加锁状态,而且一次只有一个线程可以对其加锁。读写锁有3种状态:

  1. 读模式下的加锁状态(读加锁状态)
  2. 写模式下的加锁状态(写加锁状态)
  3. 不加锁状态

读写锁有如下两个规则:

  • 当读写锁处于写加锁状态时,在这个锁被解锁之前,所有试图对这个锁进行加锁操作(不管是以读模式加锁还是以写模式加锁)的线程都会被阻塞。
  • 当读写锁处于读加锁状态时,所有试图以读模式对它进行加锁的线程都可以加锁成功;但是任何以写模式对它进行加锁的线程都会被阻塞,直到所有持有读模式锁的线程释放它们的锁为止。 虽然各操作系统对读写锁的实现各不相同,但当读写锁处于读模式加锁状态,而这时有一个线程试图以写模式获取锁时,该线程会被阻塞;而如果另一线程以读模式获取锁,则会成功获取到锁,对共享资源进行读操作。

读写锁非常适合于对共享数据读的次数远大于写的次数的情况。读写锁也叫做共享互斥锁。当读写锁是读模式锁住时,就可以说成是共享模式锁住。当它是写模式锁住时,就可以说成是互斥模式锁住。 因此,读写锁比互斥锁具有更高的并行性。

读写锁相关操作

#include <pthread.h> 

pthread_rwlock_t rwlock; //定义读写锁
pthread_rwlockattr_t attr; //定义读写锁属性

/* 初始化读写锁 */ 
pthread_rwlock_init(&rwlock, &attr);

int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock); // 以读模式对读写锁进行上锁
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock); // 以写模式对读写锁进行上锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock); // 解锁

/*
* 参数 rwlock 指向读写锁对象。调用成功返回 0,失败返回一个非 0 值的错误码。
* 当读写锁处于读模式加锁状态时,其它线程调用pthread_rwlock_rdlock()函数可以成功获取到锁
*   如果调用 pthread_rwlock_wrlock()函数则不能获取到锁,从而陷入阻塞等待状态。
* 当读写锁处于写模式加锁状态时,其它线程调用以读模式或写模式加锁操作均会获取锁失败,从而陷入阻塞等待状态
*/

// 如果线程不希望被阻塞,可以调用以下两个函数进行加锁,如果不可以获取锁时。这两个函数都会立马返回错误,错误码为 EBUSY
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock); 
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock); 

/* 使用完之后 */
pthread_rwlock_destroy(&rwlock); //销毁读写锁 
pthread_rwlockattr_destroy(&attr); //销毁读写锁属性对象

读写锁demo

使用读写锁来实现线程同步,全局变量 g_count 作为线程间的共享变量,主线程中创建5个读取g_count变量的线程,它们使用同一个函数read_thread,这 5 个线程仅仅对 g_count 变量进行读取,并将其打印出来,连带打印线程的编号(1-5);主线程中还创建5个写g_count变量的线程,它们使用同一个函数write_thread将 g_count 变量的值进行累加,循环10次,每次将g_count变量的值在原来的基础上增加20,并将其打印出来,连带打印线程的编号(1-5)

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

static pthread_rwlock_t rwlock; //定义读写锁
static int g_count = 0;

static void *read_thread(void *arg)
{
    int number = *((int *)arg);
    for (int j = 0; j < 10; j++)
    {
        pthread_rwlock_rdlock(&rwlock); //以读模式获取锁
        printf("读线程<%d>, g_count=%d\n", number + 1, g_count);
        pthread_rwlock_unlock(&rwlock); //解锁
        sleep(1);
    }

    return (void *)0;
}

static void *write_thread(void *arg)
{
    int number = *((int *)arg);
    for (int j = 0; j < 10; j++)
    {
        pthread_rwlock_wrlock(&rwlock); //以写模式获取锁
        printf("写线程<%d>, g_count=%d\n", number + 1, g_count += 20);
        pthread_rwlock_unlock(&rwlock); //解锁
        sleep(1);
    }

    return (void *)0;
}

static int nums[5] = {0, 1, 2, 3, 4};

int main(int argc, char *argv[])
{
    pthread_t tid[10];
    int j;

    /* 对读写锁进行初始化 */
    pthread_rwlock_init(&rwlock, NULL);

    /* 创建 5 个读 g_count 变量的线程 */
    for (j = 0; j < 5; j++)
        pthread_create(&tid[j], NULL, read_thread, &nums[j]);

    /* 创建 5 个写 g_count 变量的线程 */
    for (j = 0; j < 5; j++)
        pthread_create(&tid[j + 5], NULL, write_thread, &nums[j]);

    /* 等待线程结束 */
    for (j = 0; j < 10; j++)
        pthread_join(tid[j], NULL); //回收线程

    /* 销毁锁 */
    pthread_rwlock_destroy(&rwlock);
    exit(0);
}

image.png