认识Linux -- 线程同步与互斥

一 线程互斥

1.1 进程线程间的互斥相关背景概念

  • 共享资源
  • 临界资源:多线程执⾏流被保护的共享的资源就叫做临界资源
  • 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
  • 互斥:任何时刻,互斥保证有且只有⼀个执⾏流进⼊临界区,访问临界资源,通常对临界资源起保护作⽤
  • 原⼦性(后⾯讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

1.2 互斥量Mutex

  • ⼤部分情况,线程使⽤的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程⽆法获得这种变量。
  • 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
  • 多个线程并发的操作共享变量,会带来⼀些问题。
cpp 复制代码
// 操作共享变量会有问题的售票系统代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>

int ticket = 100;
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;
        }
    }
}
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);
}


为什么可能⽆法获得争取结果?

  1. if 语句判断条件为真以后,代码可以并发的切换到其他线程
  2. usleep 这个模拟漫⻓业务的过程,在这个漫⻓的业务过程中,可能有很多个线程会进⼊该代码段
  3. --ticket 操作本⾝就不是⼀个原⼦操作

-- 操作并不是原⼦操作,⽽是对应三条汇编指令:

  • load :将共享变量ticket从内存加载到寄存器中
  • update : 更新寄存器⾥⾯的值,执⾏-1操作
  • store :将新值,从寄存器写回共享变量ticket的内存地址

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

  1. 代码必须要有互斥⾏为:当代码进⼊临界区执⾏时,不允许其他线程进⼊该临界区。
  2. 如果多个线程同时要求执⾏临界区的代码,并且临界区没有线程在执⾏,那么只能允许⼀个线程进⼊该临界区。
  3. 如果线程不在临界区中执⾏,那么该线程不能阻⽌其他线程进⼊临界区。

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


互斥量的接⼝
1 初始化互斥量
初始化互斥量有两种⽅法:
⽅法1,静态分配:

cpp 复制代码
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER

⽅法2,动态分配:

cpp 复制代码
    int pthread_mutex_init(pthread_mutex_t *restrict mutex, const
    pthread_mutexattr_t *restrict attr);
参数:
    mutex:要初始化的互斥量
    attr:NULL

2 销毁互斥量
销毁互斥量需要注意:

  • 使⽤ PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
  • 不要销毁⼀个已经加锁的互斥量
  • 已经销毁的互斥量,要确保后⾯不会有线程再尝试加锁
cpp 复制代码
int pthread_mutex_destroy(pthread_mutex_t *mutex);

3 互斥量加锁和解锁

cpp 复制代码
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号

调⽤ pthread_ lock 时,可能会遇到以下情况:

  • 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
  • 发起函数调⽤时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调⽤会陷⼊阻塞(执⾏流被挂起),等待互斥量解锁。

那么这样,按照上面所学的,我们来改进一下代码:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <sched.h>
int ticket = 100;
pthread_mutex_t mutex;
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;
        }
    }
    return nullptr;
}
int main(void)
{
    pthread_t t1, t2, t3, t4;
    pthread_mutex_init(&mutex, NULL);
    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);
    pthread_mutex_destroy(&mutex);
}

1.3 互斥量实现原理探究

  • 经过上⾯的例⼦,⼤家已经意识到单纯的 i++ 或者 ++i 都不是原⼦的,有可能会有数据⼀致性问题
  • 为了实现互斥锁操作,⼤多数体系结构都提供了swap或exchange指令,该指令的作⽤是把寄存器和内存单元的数据相交换,由于只有⼀条指令,保证了原⼦性,即使是多处理器平台,访问内存的 总线周期也有先后,⼀个处理器上的交换指令执⾏时另⼀个处理器的交换指令只能等待总线周期。 现在我们把lock和unlock的伪代码改⼀下

二 线程同步

2.1 条件变量

当⼀个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
例如⼀个线程访问队列时,发现队列为空,它只能等待,只到其它线程将⼀个节点添加到队列中。这种情况就需要⽤到条件变量。

2.2 同步概念和竞态条件

同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从⽽有效避免饥饿问题,叫做同步
竞态条件:因为时序问题,⽽导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解

2.3 条件变量函数

1 初始化

cpp 复制代码
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t
*restrict attr);
参数:
    cond:要初始化的条件变量
    attr:NULL

2 销毁

cpp 复制代码
int pthread_cond_destroy(pthread_cond_t *cond)

3 等待条件满⾜

cpp 复制代码
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict
mutex);
参数:
    cond:要在这个条件变量上等待
    mutex:互斥量,后⾯详细解释

4 唤醒等待

cpp 复制代码
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);

简单案例:

  • 我们先使⽤PTHREAD_COND/MUTEX_INITIALIZER进⾏测试,对其他细节暂不追究
  • 然后将接⼝更改成为使⽤ pthread_cond_init/pthread_cond_destroy 的⽅式,⽅便后续进⾏封装
cpp 复制代码
#include <iostream>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void *active(void *arg)
{
    std::string name = static_cast<const char *>(arg);
    while (true)
    {
        pthread_mutex_lock(&mutex);
        pthread_cond_wait(&cond, &mutex);
        std::cout << name << " 活动..." << std::endl;
        pthread_mutex_unlock(&mutex);
    }
}
int main(void)
{
    pthread_t t1, t2;
    pthread_create(&t1, NULL, active, (void *)"thread-1");
    pthread_create(&t2, NULL, active, (void *)"thread-2");
    sleep(3); // 可有可⽆,这⾥确保两个线程已经在运⾏
    while (true)
    {
        // 对⽐测试
        // pthread_cond_signal(&cond); // 唤醒⼀个线程
        pthread_cond_broadcast(&cond); // 唤醒所有线程
        sleep(1);
    }
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
}

2.4 生产者消费者模型

321原则(便于记忆)

2.4.1 为何要使用生产者消费者模型

⽣产者消费者模式就是通过⼀个容器来解决⽣产者和消费者的强耦合问题。⽣产者和消费者彼此之间不直接通讯,⽽通过阻塞队列来进⾏通讯,所以⽣产者⽣产完数据之后不⽤等待消费者处理,直接扔给阻塞队列,消费者不找⽣产者要数据,⽽是直接从阻塞队列⾥取,阻塞队列就相当于⼀个缓冲区,平衡了⽣产者和消费者的处理能⼒。这个阻塞队列就是⽤来给⽣产者和消费者解耦的。

2.4.2 生产者消费者模型的优点
  • 解耦
  • ⽀持并发
  • ⽀持忙闲不均

2.5 基于BlockingQueue的生产者消费者模型

2.5.1 BlockingQueue

在多线程编程中阻塞队列(Blocking Queue)是⼀种常⽤于实现⽣产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放⼊了元素;当队列满时,往队列⾥存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)

2.6 为什么 pthread_cond_wait 需要互斥量?

条件等待是线程间同步的⼀种⼿段,如果只有⼀个线程,条件不满⾜,⼀直等下去都不会满⾜,所以必须要有⼀个线程通过某些操作,改变共享变量,使原先不满⾜的条件变得满⾜,并且友好的通知等待在条件变量上的线程。
条件不会⽆缘⽆故的突然变得满⾜了,必然会牵扯到共享数据的变化。所以⼀定要⽤互斥锁来保护。没有互斥锁就⽆法安全的获取和修改共享数据。

按照上⾯的说法,我们设计出如下的代码:先上锁,发现条件不满⾜,解锁,然后等待在条件变量上不就⾏了,如下代码:

cpp 复制代码
// 错误的设计
pthread_mutex_lock(&mutex);
while (condition_is_false) {
pthread_mutex_unlock(&mutex);
//解锁之后,等待之前,条件可能已经满⾜,信号已经发出,但是该信号可能被错过
pthread_cond_wait(&cond, &mutex);
pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);

由于解锁和等待不是原⼦操作。调⽤解锁之后, pthread_cond_wait 之前,如果已经有其他
线程获取到互斥量,摒弃条件满⾜,发送了信号,那么 pthread_cond_wait 将错过这个信
号,可能会导致线程永远阻塞在这个 pthread_cond_wait 。所以解锁和等待必须是⼀个原⼦
操作。
int pthread_cond_wait(pthread_cond_ t *cond,pthread_mutex_ t *mutex); 进⼊该函数后,会去看条件量等于0不?等于,就把互斥量变成1,直到cond_ wait返回,把条件量改成1,把互斥量恢复成原样。

2.7 条件变量使用规范

等待条件代码

cpp 复制代码
pthread_mutex_lock(&mutex);
while (条件为假) //if??
     pthread_cond_wait(cond, mutex);
修改条件
    pthread_mutex_unlock(&mutex);

给条件发送信号代码

cpp 复制代码
pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);

2.8 POSIX信号

POSIX信号量和SystemV信号量作⽤相同,都是⽤于同步操作,达到⽆冲突的访问共享资源⽬的。但POSIX可以⽤于线程间同步。
1 初始化信号量

cpp 复制代码
#include <semaphore.h>
    int sem_init(sem_t *sem, int pshared, unsigned int value);
参数:
    pshared:0表⽰线程间共享,⾮零表⽰进程间共享
    value:信号量初始值

2 销毁信号量

cpp 复制代码
int sem_destroy(sem_t *sem);

3 等待信号量

cpp 复制代码
功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t *sem); //P()

4 发布信号量

cpp 复制代码
功能:发布信号量,表⽰资源使⽤完毕,可以归还资源了。将信号量值加1。
int sem_post(sem_t *sem);//V()
2.8.1 基于环形队列的生产消费模型

环形队列采⽤数组模拟,⽤模运算来模拟环状特性

环形结构起始状态和结束状态都是⼀样的,不好判断为空或者为满,所以可以通过加计数器或者标记位来判断满或者空。另外也可以预留⼀个空的位置,作为满的状态

但是我们现在有信号量这个计数器,就很简单的进⾏多线程间的同步过程。

三 线程池

下⾯开始,我们结合我们之前所做的所有封装,进⾏⼀个线程池的设计。在写之前,我们要做如下准备

  • 准备线程的封装
  • 准备锁和条件变量的封装
  • 引⼊⽇志,对线程进⾏封装

3.1 日志与策略模式

什么是设计模式

IT⾏业这么⽕, 涌⼊的⼈很多. 俗话说林⼦⼤了啥⻦都有. ⼤佬和菜鸡们两极分化的越来越严重. 为了让菜鸡们不太拖⼤佬的后腿, 于是⼤佬们针对⼀些经典的常⻅的场景, 给定了⼀些对应的解决⽅案, 这个就是 设计模式

⽇志认识

计算机中的⽇志是记录系统和软件运⾏中发⽣事件的⽂件,主要作⽤是监控运⾏状态、记录异常信息,帮助快速定位问题并⽀持程序员进⾏问题修复。它是系统维护、故障排查和安全管理的重要⼯具。

⽇志格式以下⼏个指标是必须得有的:

  • 时间戳
  • ⽇志等级
  • ⽇志内容

以下⼏个指标是可选的

  • ⽂件名⾏号
  • 进程,线程相关id信息等

3.2 线程池设计

线程池:
⼀种线程使⽤模式。线程过多会带来调度开销,进⽽影响缓存局部性和整体性能。⽽线程池维护着多个线程,等待着监督管理者分配可并发执⾏的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利⽤,还能防⽌过分调度。可⽤线程数量应该取决于可⽤的并发处理器、处理器内核、内存、⽹络sockets等的数量。
线程池的应⽤场景:

  • 需要⼤量的线程来完成任务,且完成任务的时间⽐较短。 ⽐如WEB服务器完成⽹⻚请求这样的任务,使⽤线程池技术是⾮常合适的。因为单个任务⼩,⽽任务数量巨⼤,你可以想象⼀个热⻔⽹站的点击次数。 但对于⻓时间的任务,⽐如⼀个Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间⽐线程的创建时间⼤多了。
  • 对性能要求苛刻的应⽤,⽐如要求服务器迅速响应客⼾请求。
  • 接受突发性的⼤量请求,但不⾄于使服务器因此产⽣⼤量线程的应⽤。突发性⼤量客⼾请求,在没有线程池情况下,将产⽣⼤量线程,虽然理论上⼤部分操作系统线程数⽬最⼤值不是问题,短时间内产⽣⼤量线程可能使内存到达极限,出现错误.

线程池的种类

  • 创建固定数量线程池,循环从任务队列中获取任务对象,获取到任务对象后,执⾏任务对象中的任务接⼝
  • 浮动线程池,其他同上

3.3 线程单例模式

3.3.1 什么是单例模式

单例模式 是一种创建型设计模式,它确保一个类只有一个实例 ,并提供一个全局访问点来获取这个实例。

简单来说,单例模式就像一个公司只有一个CEO一样,无论你在哪个部门,需要找CEO做决策时,你找到的都是同一个人,不可能出现两个CEO。

3.3.2 单例模式的特点
  1. 一个类只有一个实例

  2. 该实例必须由该类自行创建

  3. 必须向整个系统提供该实例

四 线程安全和重入问题

概念
**线程安全:**就是多个线程在访问共享资源时,能够正确地执⾏,不会相互⼲扰或破坏彼此的执⾏结果。⼀般⽽⾔,多个线程并发同⼀段只有局部变量的代码时,不会出现不同的结果。但是对全局变量或者静态变量进⾏操作,并且没有锁保护的情况下,容易出现该问题。
**重⼊:**同⼀个函数被不同的执⾏流调⽤,当前⼀个流程还没有执⾏完,就有其他的执⾏流再次进⼊,我们称之为重⼊。⼀个函数在重⼊的情况下,运⾏结果不会出现任何不同或者任何问题,则该函数被称为可重⼊函数,否则,是不可重⼊函数。
学到现在,其实我们已经能理解重⼊其实可以分为两种情况:

  1. 多线程重⼊函数
  2. 信号导致⼀个执⾏流重复进⼊函数

常⻅的线程不安全的情况

  • 不保护共享变量的函数
  • 函数状态随着被调⽤,状态发⽣变化的函数
  • 返回指向静态变量指针的函数
  • 调⽤线程不安全函数的函数

常⻅的线程安全的情况

  • 每个线程对全局变量或者静态变量只有读取的权限,⽽没有写⼊的权限,⼀般来说这些线程是安全的
  • 类或者接⼝对于线程来说都是原⼦操作
  • 多个线程之间的切换不会导致该接⼝的执⾏结果存在⼆义性

常⻅不可重⼊的情况

  • 调⽤了malloc/free函数,因为malloc函数是⽤全局链表来管理堆的
  • 调⽤了标准I/O库函数,标准I/O库的很多实现都以不可重⼊的⽅式使⽤全局数据结构
  • 可重⼊函数体内使⽤了静态的数据结构

常⻅可重⼊的情况

  • 不使⽤全局变量或静态变量

  • 不使⽤ malloc或者new开辟出的空间

  • 不调⽤不可重⼊函数

  • 不返回静态或全局数据,所有数据都有函数的调⽤者提供

  • 使⽤本地数据,或者通过制作全局数据的本地拷⻉来保护全局数据

结论:
不要被上⾯绕⼝令式的话语唬住,你只要仔细观察,其实对应概念说的都是⼀回事。
可重⼊与线程安全联系

  • 函数是可重⼊的,那就是线程安全的(其实知道这⼀句话就够了)
  • 函数是不可重⼊的,那就不能由多个线程使⽤,有可能引发线程安全问题
  • 如果⼀个函数中有全局变量,那么这个函数既不是线程安全也不是可重⼊的。

可重⼊与线程安全区别

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

注意:

  • 如果不考虑 信号导致⼀个执⾏流重复进⼊函数 这种重⼊情况,线程安全和重⼊在安全⻆度不做区分
  • 但是线程安全侧重说明线程访问公共资源的安全情况,表现的是并发线程的特点
  • 可重⼊描述的是⼀个函数是否能被重复进⼊,表⽰的是函数的特点

五 常见锁概念

5.1 死锁

死锁是指在⼀组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占⽤不会释放的资源⽽处于的⼀种永久等待状态。
为了⽅便表述,假设现在线程A,线程B必须同时持有锁1和锁2,才能进⾏后续资源的访问

申请⼀把锁是原⼦的,但是申请两把锁就不⼀定了
造成的结果是

5.2 死锁必要四个条件

互斥条件:⼀个资源每次只能被⼀个执⾏流使⽤(好理解,不做解释)

请求与保持条件:⼀个执⾏流因请求资源⽽阻塞时,对已获得的资源保持不放

不剥夺条件:⼀个执⾏流已获得的资源,在末使⽤完之前,不能强⾏剥夺

循环等待条件: 若⼲执⾏流之间形成⼀种头尾相接的循环等待资源的关系

5.3 避免死锁

死锁的四个必要条件:

  1. 互斥

  2. 持有并等待

  3. 不可抢占

  4. 循环等待

破坏"互斥"条件

  • 条件:资源是互斥的,即一个资源一次只能被一个进程持有。

  • 如何破坏:让资源变得可以共享,即允许多个进程同时访问该资源。

破坏"持有并等待"条件

  • 条件:一个进程在持有至少一个资源的同时,又在等待获取其他进程持有的资源。

  • 如何破坏 :要求进程在开始执行前,必须一次性申请其所需的所有资源

破坏"不可抢占"条件

    • 条件:进程已获得的资源在未使用完之前,不能被其他进程强行抢占,只能由该进程主动释放。

    • 如何破坏 :允许系统从进程手中强制抢占资源

破坏"循环等待"条件

  • 条件 :存在一个进程资源的环形链,其中每个进程都在等待下一个进程所持有的资源。P1等P2的资源,P2等P3的资源,...,Pn等P1的资源

  • 如何破坏 :对系统中的所有资源类型进行全局线性排序 (即给每种资源一个编号),并强制要求所有进程必须按照递增的顺序申请资源

六 STL,智能指针和线程安全

6.1 STL中的容器是否是线程安全的?

不是.
原因是, STL 的设计初衷是将性能挖掘到极致, ⽽⼀旦涉及到加锁保证线程安全, 会对性能造成巨⼤的影响.
⽽且对于不同的容器, 加锁⽅式的不同, 性能可能也不同(例如hash表的锁表和锁桶).因此 STL 默认不是线程安全. 如果需要在多线程环境下使⽤, 往往需要调⽤者⾃⾏保证线程安全

6.2 智能指针是否是线程安全的?

对于 unique_ptr, 由于只是在当前代码块范围内⽣效, 因此不涉及线程安全问题.
对于 shared_ptr, 多个对象需要共⽤⼀个引⽤计数变量, 所以会存在线程安全问题. 但是标准库实现的时候考虑到了这个问题, 基于原⼦操作(CAS)的⽅式保证 shared_ptr 能够⾼效, 原⼦的操作引⽤计数.

相关推荐
lly2024062 小时前
SQL UPDATE 语句详解
开发语言
吴梓穆2 小时前
UE5 C++ 两种枚举
开发语言·c++·ue5
飞Link2 小时前
pprint 全量技术手册:复杂数据结构的结构化输出引擎
开发语言·前端·python
意疏2 小时前
【C语言】解决VScode中文乱码问题
c语言·开发语言·vscode
星辰徐哥2 小时前
异步定时任务系统的设计与Rust实战集成
开发语言·后端·rust
被摘下的星星2 小时前
Java接口需要注意的细节
java·开发语言
培风图南以星河揽胜2 小时前
幻想简历!博主本人期望的 AI Agent 全栈简历:Java + Python + Vue3 跨语言实战,代码已开源!
java·人工智能·python
独特的螺狮粉2 小时前
开源鸿蒙跨平台Flutter开发:手机清理小助手应用
开发语言·flutter·游戏·智能手机·开源·harmonyos·鸿蒙
星辰徐哥2 小时前
C++测试与调试:确保代码质量与稳定性
开发语言·c++