多线程的互斥问题和互斥锁解决互斥问题

多线程的互斥问题和互斥锁解决互斥问题

多线程抢票问题

大部分情况下,线程使用的数据都是局部变量 ,变量的地址空间在线程栈空间内,这种情况下,局部变量归属单个线程其他线程无法直接访问这种变量

但有时很多变量都需要在线程间共享 ,这样的变量称为共享变量。通过共享变量,可以实现部分数据的共享,完成线程之间的交互。但多个线程并发地操作共享变量,会带来一些问题。

多线程抢票系统

例如这里定义一个抢票系统,让多个线程执行同一个抢票逻辑:

c 复制代码
// 操作共享变量会有问题的售票系统代码
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int ticket = 5;
void *route(void *arg) {
    char *id = (char *)arg;
    while (1)
        if (ticket > 0) {
            usleep(1000);
            printf("%s sells ticket:%d\n", id, ticket);
            ticket--;
        } else
            break;
    return nullptr;
}

void f1() {
    pthread_t t1;
    pthread_create(&t1, NULL, route, (void *)("thread 1"));
    pthread_join(t1, NULL);
    printf("Function f1 final.\n");
    ticket = 10;
}

void f2() {
    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, NULL, route, (void *)("thread 1"));
    pthread_create(&t2, NULL, route, (void *)("thread 2"));
    pthread_create(&t3, NULL, route, (void *)("thread 3"));
    pthread_create(&t4, NULL, route, (void *)("thread 4"));
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);
}

int main(void) {
    f1(); // 单线程抢票情况
    f2(); // 多线程抢票情况
    return 0;
}

f1 是单线程抢票,f2 是多线程抢票。执行结果:

bash 复制代码
[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -std=c++11 -g -lpthread
./a.exe
thread 1 sells ticket:5
thread 1 sells ticket:4
thread 1 sells ticket:3
thread 1 sells ticket:2
thread 1 sells ticket:1 # 单线程一切正常
Function f1 final.
# 省略部分输出
thread 1 sells ticket:1
thread 2 sells ticket:0
thread 4 sells ticket:-1 # 多线程下数据出现问题
thread 3 sells ticket:-2
[Bjarne@VM-8-8-centos cppTest]$ 

抢票时票数出现了负数,相当于现实中多卖出去几张票,最终一定会出现买到多余的票的客人,和商家产生冲突。

分析抢票系统打印负数的原因

理论铺垫

重点分析多个线程在执行这个函数时出现的情况。

cpp 复制代码
int ticket = 5;
void *route(void *arg) {
    char *id = (char *)arg;
    while (1)
        if (ticket > 0) {
            usleep(1000);
            printf("%s sells ticket:%d\n", id, ticket);
            ticket--;
        } else
            break;
}

将这个代码使用 g++ a.cpp -S -o a.s -lpthread (使用反汇编工具 objdump -S a.cpp > a.s 也行)翻译成汇编语言,并对循环部分进行解读:

assembly 复制代码
.L5:
	# 对应 if (ticket > 0) 的代码块
    movl    ticket(%rip), %eax# 1. 将全局变量 ticket 的值加载到寄存器 eax
    testl   %eax, %eax # 2. 测试eax是否为0(即ticket是否>0)
    jle     .L2 # 3.如果ticket≤0,跳转到循环外的.L2(退出循环)

    # 对应usleep(1000)
    movl    $1000, %edi # 4. usleep(1000) 的参数
    call    usleep # 5. 调用usleep,模拟售票前的耗时操作

    # 对应printf语句
    movl    ticket(%rip), %edx# 6.再次加载ticket到edx作为printf的参数
    movq    -8(%rbp), %rax# 7. 加载线程 id 字符串地址
    movq    %rax, %rsi  # 8. 作为 printf 的第二个参数
    movl    $.LC0, %edi # 9. 格式字符串地址("%s ...:%d\n")
    movl    $0, %eax # 10.清除eax,用于printf的向量寄存器标记
    call    printf      # 11. 打印

    # 对应 ticket-- 操作
    movl    ticket(%rip), %eax# 12. 第三次加载 ticket 到 eax
    subl    $1, %eax          # 13. eax = ticket - 1
    movl    %eax, ticket(%rip)# 14. 将新值写回 ticket

    jmp     .L5               # 15. 跳回循环开头 .L5

注意到无论是判断 ticket 是否大于 0 ,还是对 ticket 执行减减操作,都需要将变量的数据从内存拷贝到寄存器 eax , CPU 才能直接访问 eax 内的内容。然后 CPU 将数据拷贝回内存,才完成了一整个 ticket-- 的操作。

每个 CPU 都只有 1 套寄存器,但寄存器的内容却是属于当前线程 ,或者说寄存器的内容是线程私有的。当线程进行切换时,寄存器内的内容会被新线程的内容覆盖。

CPU 的主要功能可以概括为:从内存中读取指令解释指令执行相应的操作,从而控制计算机的各个部件协调工作。具体包括以下几个核心方面:

  1. 指令控制:程序由一系列指令组成,CPU 负责自动、顺序地从内存中取出指令,并决定接下来执行哪一条(包括处理跳转、循环等)。
  2. 算术逻辑运算:通过算术逻辑单元(ALU)执行加、减、乘、除等算术运算,以及与、或、非、异或等逻辑运算。
  3. 数据传送:在 CPU 内部寄存器、高速缓存、内存以及 I/O 设备之间移动数据(如从内存读数据到寄存器,或将计算结果写回内存)。
  4. 程序流程控制:根据比较结果或中断信号,改变指令的执行顺序(例如条件跳转、函数调用与返回、循环控制)。
  5. 中断处理:响应外部或内部的中断请求,暂停当前任务,转去执行特定的中断服务程序,完成后再恢复原任务。

简单来说,CPU 的基本工作就是 取指令 → \rightarrow → 译码 → \rightarrow → 执行 的循环,并在这个循环中完成各种计算和对系统的控制。

所有线程都共享页表,所以所有线程都可以访问公共的全局变量 ticket

只要用户的数据被加载 (拷贝) 到了 CPU 的寄存器内,都叫做当前线程的上下文切换。只有内核当中的用于管理的内核级别的寄存器,在操作系统当中可能不切换,例如管理地址、空间的,管理页表相关的状态等。

分析原因

这里线程之所以会打印出负数,是因为在打印之前,刚要准备将 ticket 的内容拷贝到寄存器中时,该线程的时间片刚好结束 ,切换到别的几个线程,然后其他线程将全局变量 ticket 的内容变成了 0 甚至负数。当回到要打印这个变量的线程时,这个线程再将已变成或即将变成 0 甚至负数的 ticket 的内容加载到 eax 寄存器进行 ticket-=1 的操作,所以就打印出了负数。

这个逻辑若是单个线程,则不会有任何问题,但线程只要存在多个 ,就一定会存在 ,只是发生类似的事的概率在数据量较低时会很小,但会随着数据量的增大发生的概率 也会随之增大

Linux线程互斥

只要是全局的所有线程就都可以访问的资源 例如全局变量等,就把这种资源叫共享资源

多个线程在并发调度访问同一个公共资源时,可能会出现访问数据不一致 的问题。这类问题在操作系统原理中叫做多线程的并发问题 ,即多线程要互相竞争对应的资源。例如之前的多线程抢票问题。

并发调度无法控制,所以想观察并发访问全局资源,导致数据在并发访问的情况下出现问题的这种场景是看不出来的,或没有办法直接控制的,但可以间接控制

并发问题最常见的情况有 2 种,即互斥或同步。这里介绍互斥。

对公共资源进行保护方式

之前的抢票程序会出现负数的原因还可以这样分析:

  • if 语句判断条件为真以后,代码可以并发的切换到其他线程。
  • usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段。
  • --ticket 操作本身就不是一个原子操作。

要解决以上问题,需要做到 3 点:

  1. 代码必须要有互斥行为 :当一个线程进入临界区 执行时,不允许其他线程进入该临界区
  2. 如果多个线程同时要求执行临界区 的代码,并且临界区没有线程在执行 ,那么只能允许一个线程进入该临界区
  3. 如果线程不在临界区中执行 ,那么该线程不能阻止其他线程进入临界区 。这里不在临界区中执行是指临界区的代码指令已执行完因为时间片导致线程被换下不属于这种情况

第 3 点可这样理解:住酒店时可租一个单人房,人一进去就把门锁上。期间人走的时候可以把钥匙带上,除了业主之外不允许任何人访问。

要做到这 3 点,本质上就是需要一把锁。Linux 上提供的这把锁叫互斥量

互斥相关背景概念

这些都是操作系统原理的重要概念。这里有的内容不按主流课本的内容进行描述。

  • 临界资源多线程执行流共享被保护的资源就叫做临界资源。

  • 临界区 :每个线程内部访问临界资源的代码 ,就叫做临界区。任何多线程并发问题和其他没有访问公共资源的代码无关只和访问公共资源的代码有关 。例如抢票函数 route

  • 互斥 :任何时刻,互斥保证有且只有一个执行流进入临界区访问临界资源,通常对临界资源起保护作用。

  • 原子性不会被 任何调度机制打断的操作所具有的性质 ,该操作只有两态,要么完成,要么未完成。例如 C 语言代码翻译成的汇编指令具有原子性,而多条汇编指令组成的完整的用户语句不具有,所以用户语句会被打断,而汇编指令不会。

  • 并行:多个进程/线程在多个 CPU 下分别、同时运行,这称之为并行。

  • 并发:多个进程/线程在一个 CPU 下采用进程/线程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发。

互斥量的相关接口

先学习如何使用,再分析原理。

这里参考 man pthread_mutex_init 。 mutex 这个英文单词可翻译为互斥锁或互斥量。

cpp 复制代码
#include <pthread.h>
int pthread_mutex_destroy(pthread_mutex_t *mutex);
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
       const pthread_mutexattr_t *restrict attr);
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
# define PTHREAD_MUTEX_INITIALIZER \
  { { 0, 0, 0, 0, 0, __PTHREAD_SPINS, { 0, 0 } } }

//pthreadtypes.h
# define __PTHREAD_SPINS             0, 0

mutex 就是互斥量对象,本质是 union 联合体进行的重命名。直接定义它,就相当于定义了一把锁。被加锁的临界区,每次只能有 1 个线程访问,即使线程因为时间片被换下去也是如此。

但使用 mutex 解决多线程的并发问题还需要其他的接口。这里参考 man pthread_mutex_lock

cpp 复制代码
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

3 个接口简介:

  • pthread_mutex_lock 用于锁定mutex 引用的互斥量对象若该互斥量已被锁定 ,则调用 pthread_mutex_lock线程被阻塞直到该互斥量变为可用
  • pthread_mutex_trylock 同样用于锁定mutex 引用的互斥量对象 。但不同于 pthread_mutex_lock若该互斥量已被锁定 ,则调用 pthread_mutex_lock线程不会被阻塞,而是根据提前设定的代码继续执行。
  • pthread_mutex_unlock 用于释放mutex 引用的互斥量对象 。如果在调用 pthread_mutex_unlock 时,有线程因为由 mutex 引用的互斥量对象被锁定导致阻塞,则该互斥量将变为可用,被阻塞的线程将被唤醒并进入就绪状态。但若是多个线程,则哪个线程先被唤醒取决于系统自带的调度算法。

若调用成功,则 pthread_mutex_lockpthread_mutex_trylockpthread_mutex_unlock 函数应返回零;否则,应返回一个错误编号以指示错误。

使用全局mutex

使用 mutex 解决多线程的并发问题的思路:

  1. 定义一个 pthread_mutex_t 对象例如 mutex确保所有线程都能看到这个对象
  2. 初始化 pthread_mutex_t 对象。
  3. 在会出现多线程的并发问题的临界区使用 pthread_mutex_lockpthread_mutex_trylock 对临界区的代码块进行上锁。
  4. 临界区的代码运行完成后 ,使用 pthread_mutex_unlock 解锁

让所有线程都能看到这个对象,最简单的思路是将 mutex 定义为全局。直接将 mutex 定义在全局,需要做初始化工作,可对互斥量赋值宏 PTHREAD_MUTEX_INITIALIZER 进行初始化。

使用赋值宏 PTHREAD_MUTEX_INITIALIZER 进行初始化mutex 不需要使用 pthread_mutex_destroy 进行销毁,但改进时需要考虑细节:

  1. 加锁必须加在临界区开头。引起并发的问题,本质是多线程并行地访问了临界区。若加在全局则多线程就失去了存在的意义,代码就变成了另一种形式的单线程。
  2. 临界区的代码执行完毕后一定要解锁,否则其他线程将不能访问。所以代码的设计上要确保离开临界区时将锁全部解开。
  3. 严禁一个线程加锁另一个线程解锁。这种跨线程解锁的行为未定义,可能带来严重的后果。

使用全局的 mutex 优化的多线程抢票程序参考:

cpp 复制代码
// 操作共享变量会有问题的售票系统代码
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int ticket = 4200;

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void *route(void *arg) {
    char *id = (char *)arg;
    while (1) {
        pthread_mutex_lock(&mutex); // 加锁
        if (ticket > 0) {
            usleep(1000);
            printf("%s sells ticket:%d\n", id, ticket);
            ticket--;
            pthread_mutex_unlock(&mutex); // 解锁
        } else {
            pthread_mutex_unlock(&mutex); // 一定要在break前解锁
            break;
        }
    }
}

int main(void) { // 多线程抢票情况
    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, NULL, route, (void *)("thread 1"));
    pthread_create(&t2, NULL, route, (void *)("thread 2"));
    pthread_create(&t3, NULL, route, (void *)("thread 3"));
    pthread_create(&t4, NULL, route, (void *)("thread 4"));
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);
    return 0;
}

将抢票程序的 route 函数做如下改进是不允许的,因为 break 之后将无法解锁,此时没有线程可以访问这部分代码。

cpp 复制代码
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void *route(void *arg) {
    char *id = (char *)arg;
    while (1) {
        pthread_mutex_lock(mutex);
        if (ticket > 0) {
            usleep(1000);
            printf("%s sells ticket:%d\n", id, ticket);
            ticket--;
        } else
            break;
        pthread_mutex_lock(mutex);//不允许,因为break之后无法解锁
    }
}

再例如这个代码:

cpp 复制代码
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void *route(void *arg) {
    char *id = (char *)arg;
    while (1) {
        pthread_mutex_lock(mutex);
        if (ticket > 0) {
            usleep(1000);
            printf("%s sells ticket:%d\n", id, ticket);
            ticket--;
            pthread_mutex_lock(mutex);//不允许,因为break之后无法解锁
        } else
            break;
    }
}

申请锁本身是安全的是属于原子性操作,原理后面会解释。锁一旦被申请,直到申请锁的线程主动解锁,否则其他线程都不可再次申请全局的锁,即使申请锁的线程因为时间片到了被暂时换下其他线程也不可再次申请。

mutex 的本质是以牺牲效率为代价来解决安全问题 ,所以使用时只用在临界区。若加在别的地方,会使大量代码都是串行的,多线程就失去了存在的意义。

使用局部mutex

即在局部申请 mutex ,然后将这个锁的地址上传给所有线程。思路和全局的 mutex 是一样的,但在软件开发时不建议将锁设置为全局。

参考 man pthread_mutex_init

cpp 复制代码
#include <pthread.h>
int pthread_mutex_destroy(pthread_mutex_t *mutex);
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
       const pthread_mutexattr_t *restrict attr);
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
# define PTHREAD_MUTEX_INITIALIZER \
  { { 0, 0, 0, 0, 0, __PTHREAD_SPINS, { 0, 0 } } }

//pthreadtypes.h
# define __PTHREAD_SPINS             0, 0

restrict 关键字是 C99 引入的类型限定符 (与 constvolatile 同级),只能用于指针,也可以叫指针限定符。它向编译器 "承诺" :在 mutex 指针的生命周期内,只有这个指针本身 ,或者直接从它派生的指针会访问它所指向的对象,不会有其他指针(别名)指向同一块内存。

pthread_mutex_t 是 POSIX 线程库定义的一个结构体类型,但标准刻意没有公开其内部成员细节,因此被称为不透明类型 (Opaque Type) 。于是不同的编译器对这个关键字的底层解释不完全相同。这个类型:

  • 不能通过 .-> 访问其内部字段(除非使用系统私有头文件,但那是不可移植的)。
  • 只能通过 pthread_mutex_xxx() 函数族来操作它。
  • 这种设计强制了 API 的稳定性,不同系统可以用完全不同的内部结构,但用户代码无需修改。

restrict 的存在有两个关键原因:

  • 如果不加 restrict,编译器必须考虑指针别名 (Aliasing) 的可能性。简单来说就是可能存在全局变量指向它,或 attr 指向了 pthread_mutex_t 对象的内部,会阻止很多优化。使用 restrict 后,编译器可以放心地将互斥量的内存操作优化为寄存器访问。
  • restrict 更像是是程序员给编译器和阅读者的约定。它明确告知:"我用这个函数初始化一块全新的互斥量内存,且不会通过任何其他指针同时操作它。" 若程序员违反了这一契约(例如传入一个已被其他指针引用的互斥量),结果是未定义行为,编译器无需为此负责。这有助于静态分析工具检测潜在的错误别名使用。

简单描述其他接口:

  • pthread_mutex_init 用于初始化互斥量
  • pthread_mutex_destroy 则用于使互斥量变回未初始化的状态即销毁互斥量对象。

若成功,pthread_mutex_destroypthread_mutex_init 函数应返回 0 ;否则,应返回一个错误编号以指示错误。

所以初始化互斥量 可使用 pthread_mutex_init 动态分配,然后将互斥量的地址作为线程数据的一部分传递给所有线程。当有线程访问临界区时锁定互斥量,其他线程也能看得见。

使用 pthread_mutex_init 动态分配互斥量时,需要使用 pthread_mutex_destroy 销毁互斥量。使用 pthread_mutex_destroy 销毁互斥量需要注意:

  • 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁。

  • 不要销毁一个已经加锁的互斥量。

  • 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。

  • 若忘记使用 pthread_mutex_destroy 销毁互斥量,会带来一系列的资源泄露隐患,严重时会导致程序崩溃。

例如这里将互斥量作为线程信息的一部分进行封装,然后上传给各个线程,使得售票系统不会出现并发访问问题:

c 复制代码
// 操作共享变量会有问题的售票系统代码
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <pthread.h>
#include <string>
#include <unistd.h>
using std::string;
int ticket = 4200;

// 将互斥量和其他线程信息封装
struct ThreadInfo {
    ThreadInfo(pthread_mutex_t *p = nullptr, const string &nm = "")
        : pmutex(p), name(nm) {}
    pthread_mutex_t *pmutex;
    string name;
};

void *route(void *arg) {
    string &id = static_cast<ThreadInfo *>(arg)->name;
    pthread_mutex_t &mutex = *static_cast<ThreadInfo *>(arg)->pmutex;
    while (1) {
        pthread_mutex_lock(&mutex); // 加锁
        if (ticket > 0) {
            usleep(1000);
            printf("%s sells ticket:%d\n", id.c_str(), ticket);
            ticket--;
            pthread_mutex_unlock(&mutex); // 解锁
        } else {
            pthread_mutex_unlock(&mutex); // 一定要在break前解锁
            break;
        }
    }
}

int main(void) { // 多线程抢票情况
    pthread_mutex_t mutex;
    pthread_mutex_init(&mutex, nullptr);
    pthread_t t1, t2, t3, t4;
    // 局部的临时变量,可在堆区申请
    ThreadInfo ti[4] = {{&mutex, "thread1"},
                        {&mutex, "thread2"},
                        {&mutex, "thread3"},
                        {&mutex, "thread4"}};
    pthread_create(&t1, NULL, route, (void *)(ti + 0));
    pthread_create(&t2, NULL, route, (void *)(ti + 1));
    pthread_create(&t3, NULL, route, (void *)(ti + 2));
    pthread_create(&t4, NULL, route, (void *)(ti + 3));
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);
    pthread_mutex_destroy(&mutex); // 建议销毁
    return 0;
}

智能指针 一样,还可通过类的构造、析构函数会自主运行的特性设置锁的守护者类 ,此时只需要在临界区申请一个守护者对象即可完成上锁的功能。

这里使用类组合的方式,将自制的互斥量类和锁的守护者类进行结合,通过锁的守护者类的生命周期实现对临界区的加锁和解锁 功能。

cpp 复制代码
// 操作共享变量会有问题的售票系统代码
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <pthread.h>
#include <string>
#include <unistd.h>
using std::string;
int ticket = 4200;

// 将互斥量和其他线程信息封装
struct ThreadInfo {
    ThreadInfo(pthread_mutex_t *p = nullptr, const string &nm = "")
        : pmutex(p), name(nm) {}
    pthread_mutex_t *pmutex;
    string name;
};

// 锁类或互斥量类
class Mutex {
public:
    Mutex(pthread_mutex_t *pm) : mutex(pm) {}
    void lockstart() {
        pthread_mutex_lock(mutex);
    }
    void lockclose() {
        pthread_mutex_unlock(mutex);
    }

private:
    pthread_mutex_t *mutex;
};

// 锁的守护者类
class LockGuard {
public:
    LockGuard(pthread_mutex_t *plock) : mutex(plock) {
        mutex.lockstart();
    }
    ~LockGuard() {
        mutex.lockclose();
    }

private:
    Mutex mutex; // 类的组合
};

void *route(void *arg) {
    string &id = static_cast<ThreadInfo *>(arg)->name;
    pthread_mutex_t &mutex = *static_cast<ThreadInfo *>(arg)->pmutex;
    while (1) {
        // 假设这里做一些初始化工作
        { // 引入局部作用域,可使用花括号强行将对象的生命周期绑定在局部
            LockGuard lockGuard(&mutex);
            if (ticket > 0) {
                usleep(1000);
                printf("%s sells ticket:%d\n", id.c_str(), ticket);
                ticket--;
            } else {
                break;
            }
        }
        // 假设这里做一些收尾的工作
    }
}

int main(void) { // 多线程抢票情况
    pthread_mutex_t mutex;
    pthread_mutex_init(&mutex, nullptr);
    pthread_t t1, t2, t3, t4;
    // 局部的临时变量,可在堆区申请
    ThreadInfo ti[4] = {{&mutex, "thread1"},
                        {&mutex, "thread2"},
                        {&mutex, "thread3"},
                        {&mutex, "thread4"}};
    pthread_create(&t1, NULL, route, (void *)(ti + 0));
    pthread_create(&t2, NULL, route, (void *)(ti + 1));
    pthread_create(&t3, NULL, route, (void *)(ti + 2));
    pthread_create(&t4, NULL, route, (void *)(ti + 3));
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);
    pthread_mutex_destroy(&mutex); // 建议销毁
    return 0;
}

除了需要临时解锁重新加锁转移所有权等的部分少见的场景外,都可以使用花括号强制缩小锁的守护者对象的作用域和生命周期。少见的场景在软件开发中会逐渐提及。

多线程的饥饿问题

在之前的实验中,若是 CentOS ,很可能会出现一个线程把所有的票都抢光的问题。但若是其他的例如 Ubuntu ,可能不会有这个问题,因为 pthread 的原生线程库的不同版本,尤其是关于线程处理的策略可能不一样,使得在这个现象上出现显著差异。

这个问题在这里的代码设计是合理的,也是被允许的。因为这里的要求仅仅是任何一个时刻只允许一个执行流进入到临界区里面,并没有规定一个线程进入临界区后短时间内不可再进入临界区

外加线程调度算法的设计会使得有的线程的竞争性很强,即使退出临界区 了,也可以再次进入临界区 。这种现象带来的直接后果 是一个或多个线程长期无法获得执行所需的资源(例如 CPU 时间片、互斥锁、信号量等,这里是互斥锁),导致其任务迟迟无法推进。

粗浅的理解:哪个线程的拳头大,优先级高,哪个线程就可以优先占据临界区。

这种一个或多个线程长期无法获得执行所需的资源 ,导致其任务迟迟无法推进的现象称为多线程的饥饿(Starvation)问题。

可通过使线程强制休眠缓解饥饿问题,但无法彻底阻止。想要彻底阻止饥饿问题还需要其他方案,详细后续再描述。

cpp 复制代码
int ticket = 4200;
void *route(void *arg) {
    string &id = static_cast<ThreadInfo *>(arg)->name;
    pthread_mutex_t &mutex = *static_cast<ThreadInfo *>(arg)->pmutex;
    while (1) {
        // 假设这里做一些初始化工作
        { // 引入局部作用域,可使用花括号强行将对象的生命周期绑定在局部
            LockGuard lockGuard(&mutex); // 锁的守护者类
            if (ticket > 0) {
                usleep(1000);
                printf("%s sells ticket:%d\n", id.c_str(), ticket);
                ticket--;
            } else {
                break;
            }
        }
        // 假设这里做一些收尾的工作
        usleep(100); // 规定收尾时的100ms内不允许线程再次进入临界区
    }
}

要解决这个现象,本质是让线程在访问锁,访问临界区域这件事情上具备某种顺序。这是线程的同步问题。后续会单独开一篇。

互斥量实现原理探究

为了实现互斥锁操作,大多数芯片的体系结构(例如 x86、AMD)都会在汇编层面提供 swapxchg (exchange)指令,该指令的作用是把寄存器和内存单元的数据相交换。由于只有 1 条汇编指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。

此时就可以大致修改 lockunlock 的伪代码:

c 复制代码
lock:
	movb $0, %al
	xchgb %al, mutex
	if(al寄存器的内容 > 0){
		return 0;
	}
	else{
		挂起等待;
	}
	goto lock;
unlock:
	movb $1, mutex
    唤醒等待Mutex的线程;
	return 0;

因此互斥量的核心 可认为是一个要么 0 要么 1 的整数 ,或拥有同等效果的比特位。规定 1 表示可申请, 0 表示不可申请,默认情况下可申请。

当出现加锁操作时,可认为加锁的线程先检测 al 寄存器的内容,若为 1 则表示可申请。然后将 pthread_mutex_t 类的某个成员变量内的存储 0 的变量,通过具有原子性操作的汇编指令将寄存器 al 的内容进行置换,使得 al 寄存器的内容变成 0 。

这样即使加锁的线程因为时间片耗尽被换走,新来的线程需要执行加锁的过程,会先检测 al 寄存器的内容,发现 al 寄存器的内容为 0 ,于是选择阻塞等待或做别的事。

重入VS线程安全

重入

重入Reentrancy ):同一个函数 被不同的执行流调用,当前一个流程还没有执行完 ,就有其他的执行流再次进入,则称这种特性为重入。

一个函数在重入的情况下,运行结果不会出现任何不同(指返回值正确 ,还包括全局状态未被破坏内存未泄漏不会死锁或崩溃 。)或者任何问题(包括死锁数据竞争静态变量污染 等。),则该函数被称为可重入函数 ,否则,是不可重入函数

常见不可重入的情况

  • 调用了 malloc / free 函数 ,因为 malloc 函数是用全局链表来管理堆的。

  • 调用了标准 I/O 库函数,标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构。

  • 可重入函数体 内使用了静态或全局的数据结构

其中 STL 的空间配置器 (Allocator)在实现时,一级配置器明确调用了 mallocfree ,二级配置器使用了链表,属于静态的数据结构,所以 STL 的容器 基本上都是不可重入 的。否则一旦出现 free 已释放的空间,会引起进程崩溃。

常见可重入的情况

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

线程安全

线程安全多个线程并发同一段代码时不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。

可重入和线程安全是 2 个完全不同的概念,这 2 个概念在使用上有交集,但在概念上没有任何交集。

重入描述的是函数的特点,和线程没有关系。线程安全描述的是线程的特征。

常见的线程不安全的情况

  • 不保护共享变量的函数 。这里的共享变量常见的有静态局部变量全局变量
  • 随着被调用函数状态发生变化的函数。
  • 返回指向静态变量指针的函数。
  • 调用线程不安全函数的函数,特别是不可重入函数。

常见的线程安全的情况

  • 每个线程对全局变量 或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
  • 非增量修改的情况下,读取时先备份,用完之后再还原,同时再加锁保护,即并发编程中 "Copy-out, Work-on-copy, Copy-back" (快照-修改-回写)模式。
  • 类或者接口对于线程来说都是原子操作
  • 多个线程之间的切换不会导致 该接口的执行结果存在二义性

可重入与线程安全联系

  • 函数是可重入 的,那就是线程安全的。
  • 函数是不可重入 的,那就不能由多个线程使用,有可能引发线程安全问题
  • 如果一个函数中有全局变量静态局部变量,那么这个函数既不是线程安全也不是可重入的。

可重入与线程安全区别

  • 可重入函数线程安全函数的一种
  • 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
  • 如果将对临界资源的访问加上锁 ,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则有可能会产生死锁,因此是不可重入的。

死锁

死锁 是指在一组进程中的各个进程/线程均占有不会释放的资源 ,但因互相申请被其他进程所占用不会释放的资源 而处于的一种永久等待状态。各个进程/线程形成的互相申请对方资源的环路问题叫做循环等待条件。

死锁常出现的情况是使用多个锁,或多次加锁或解锁各种嵌套的复杂情况。粗浅理解就是自己掌握别人需要的资源 ,和自己需要的资源被别人掌握 ,于是和别人互相扯皮

死锁出现的前提是使用锁。使用锁的目的是为了让多个线程之间可以使用全局数据,实现线程间的数据传递或者通信,但使用得当就不会产生死锁。

在软件开发中需要保证不能出现死锁,这在个人开发中可进行留意,但多人开发时可能会出现。

常见的死锁出现的场景:

  • 高频、多线程、高并发访问的一些网络接口。
  • 数据库有关。
  • 多个程序员共同开发的项目。

死锁四个必要条件

  • 互斥条件:一个资源每次只能被一个执行流使用。
  • 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
  • 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺,更不用说被非加锁线程进行解锁。
  • 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。

避免死锁

  • 破坏死锁的四个必要条件
    • 例如第 2 条请求与保持条件,自身可先主动解开对方线程需要的锁,等对方将锁尽数释放后,自身即可获得所有锁。
    • 例如第 3 条不剥夺条件,引入一个管理线程强行剥夺锁。
  • 加锁顺序一致。即按照不会出问题的顺序,所有线程统一加锁、解锁。
  • 避免锁未释放的场景
  • 资源一次性分配

避免死锁算法:死锁检测算法和银行家算法等。这些算法后续有机会再进行介绍。

相关推荐
键盘会跳舞1 小时前
C++:std::pair 源码级深度剖析 —— 关联容器的基石
开发语言·c++·pair·关联容器
(Charon)1 小时前
【C++】手写线程安全队列:mutex + condition_variable 实现生产者消费者模型
c语言·开发语言·c++
数智工坊1 小时前
UECP:不确定性增强的协同感知 | 基于物理先验的BEV融合新范式
linux·ubuntu·3d·机器人
ltl10 小时前
内核追踪:ftrace、kprobe、uprobe、tracepoint 生产实战
linux
·薯条大王10 小时前
经济实惠玩云服务器|一台云服务器多人共用,子账号配置教程
java·linux·运维·服务器·汇编·c++·python
小小、码农13 小时前
Linux进程控制:从fork到exec,手写一个简易Shell
linux·运维·服务器
命运之光14 小时前
【C语言完整代码】就诊信息管理系统
java·c语言·开发语言
不会就选b14 小时前
Linux之进程间通信(二)---模拟进程池
linux·运维·服务器
命运之光14 小时前
【C语言完整代码】图书管理系统:图书馆场景下的增删改查实战
c语言·开发语言