✨✨ 欢迎大家来到贝蒂大讲堂✨✨
🎈🎈养成好习惯,先赞后看哦~🎈🎈
所属专栏:Linux学习
贝蒂的主页:Betty’s blog
与Windows
环境不同,我们在linux
环境下需要通过指令进行各操作,以下是常见操作的指令:
1. 线程互斥
1.1 基本概念
- 临界资源: 多线程执行流共享的资源叫做临界资源。
- 临界区: 每个线程内部,访问临界资源的代码,就叫做临界区。
- 互斥: 任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
- 原子性: 不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
进程之间进行通信需要先创建第三方资源,使得不同的进程能够看到同一份资源。由于这份第三方资源可以由操作系统中的不同模块提供,所以进程间通信的方式有很多种。在进程间通信中,这个第三方资源被称为临界资源,而访问第三方资源的代码则被称为临界区。
与之不同的是,多线程的大部分资源都是共享的。因此,线程之间进行通信并不需要像进程那样费力地去创建第三方资源。
例如,我们在代码中只需要在全局区定义一个count
变量,新线程可以每隔一秒对该变量进行加一操作,主线程也可以每隔一秒获取count
变量的值并进行打印。
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
using namespace std;
int count = 0;
void *Routine(void *args)
{
while (true)
{
count++;
sleep(1);
}
pthread_exit((void *)0);
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, Routine, nullptr);
while (true)
{
cout << "The value of count is " << count << endl;
sleep(1);
}
pthread_join(tid, nullptr);
return 0;
}
在当前情境下,我们相当于实现了主线程和新线程之间的通信。其中,全局变量count
起着关键作用,它被称为为临界资源,原因在于它被多个执行流所共享。而主线程中的 cout
操作以及新线程中的 count++
操作,被称作临界区。这是因为这些代码片段对临界资源进行了访问。
但是我们同样观察到打印数据并没有 1,这就是多执行流对临界资源操作常引发的数据不一致问题。
同样我们也可以下面抢票程序的实现,具体演示如果不对临界资源进行限制,可能会出现的危害。
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <cstdio>
#include <cstdlib>
using namespace std;
int tickets = 1000;
void *getTickets(void *args)
{
string name = "thread ";
name += to_string((uint64_t)args);
while (true)
{
if (tickets > 0)
{
usleep(1000);
cout << "[" << name << "]" << "get a ticket,left: " << --tickets << endl;
}
else
{
break;
}
}
cout << name << " is quit!" << endl;
pthread_exit((void *)0);
}
int main()
{
pthread_t tids[5];
for (uint64_t i = 0; i < 5; i++)
{
pthread_create(tids + i, nullptr, getTickets, (void *)i);
}
for (int i = 0; i < 5; i++)
{
pthread_join(tids[i], nullptr);
}
return 0;
}
剩余票数出现负数,这明显不符合我们的常识与预期,之所以出现这种情况,本质就是 tickets
就是我们的临界资源,--tickets
也 并不是原子的,在多执行流同时执行时就可能会发生这种问题。
为什么 --tickets
并不是原子的呢?
因为从汇编角度看,我们的
--
操作其实是不安全的,他们转成汇编,一般会对应三条汇编指令:从内存中读取数据到CPU
中;CPU
内进行操作;CPU
将结果写回内存。进程在运行的时候,随时可能被切换。
1.2 互斥量
为了解决这个问题我们就引入了互斥,保证一次只有一个执行流访问临界资源,而为了实现互斥,我们就需要保证临界区的原子性,即临界区的资源要么被执行完成,要么不执行,只存在这两态。
要做到这些,本质就是需要一把锁,所以 Linux
就引入一个锁,并将其称为互斥量。
1.3 互斥量的接口
1.3.1 初始化互斥量
我们可以使用pthread_mutex_init
初始化互斥量,使用方法如下:
- 函数原型:int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
- 参数:
mutex
:需要初始化的互斥量。attr
:初始化互斥量的属性,一般设置为nullptr
即可。
- 返回值:互斥量初始化成功返回0,失败返回错误码。
这种调用函数接口初始化互斥量的方式我们称为动态分配,除此之外,我们也能使用如下的方式进行初始化,我们将其称为静态分配。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
1.3.2 销毁互斥量
我们可以使用pthread_mutex_destory
销毁互斥量,使用方法如下:
- 函数原型:int pthread_mutex_destroy(pthread_mutex_t *mutex);
- 参数:
mutex
:需要销毁的互斥量。- 返回值:成功返回 0,失败返回错误码。
其中销毁互斥量,需要注意以下几点:
- 使用
PTHREAD_MUTEX_INITIALIZER
静态初始化的互斥量不需要销毁。- 不能销毁一个已经加锁的互斥量。
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。
1.3.3 加锁互斥量
加锁本质就是让被加锁区域的代码具有原子性,只能同时被一个线程访问。我们可以使用pthread_mutex_lock
对互斥量进行加锁,使用方法如下:
- 函数原型:int pthread_mutex_lock(pthread_mutex_t *mutex);
- 参数:
mutex
:需要加锁的互斥量。- 返回值:成功返回 0,失败返回错误码。
如果一个线程在执行过程中,遇见该接口,并且该锁已被其他线程申请,那么该线程此时就会陷入阻塞状态,等待其解锁。
1.3.4 解锁互斥量
在加完锁之后,我们不可能让所有代码只被一个执行流访问,所以我们需要合适的地方解锁,我们可以使用pthread_mutex_unlock
对互斥量进行解锁,使用方法如下:
- 函数原型:int pthread_mutex_unlock(pthread_mutex_t *mutex);
- 参数:
mutex
:需要解锁的互斥量。- 返回值:成功返回 0,失败返回错误码。
知道了这些概念之后我们就可以对前面的抢票逻辑进行修改:
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <cstdio>
#include <cstdlib>
using namespace std;
int tickets = 1000;
pthread_mutex_t mutex;
void *getTickets(void *args)
{
string name = "thread ";
name += to_string((uint64_t)args);
while (true)
{
pthread_mutex_lock(&mutex);
if (tickets > 0)
{
usleep(1000);
cout << "[" << name << "]" << "get a ticket,left: " << --tickets << endl;
pthread_mutex_unlock(&mutex);
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
}
cout << name << " is quit!" << endl;
pthread_exit((void *)0);
}
int main()
{
pthread_mutex_init(&mutex, nullptr);
pthread_t tids[5];
for (uint64_t i = 0; i < 5; i++)
{
pthread_create(tids + i, nullptr, getTickets, (void *)i);
}
for (int i = 0; i < 5; i++)
{
pthread_join(tids[i], nullptr);
}
pthread_mutex_destroy(&mutex);
return 0;
}
其实在大部分情况下,加锁本身都是有损于性能的事,因为它使多执行流由并行执行变为了串行执行,这几乎是不可避免的。所以我们需要在合适的位置加锁与解锁,尽可能减少锁引入锁带来的性能开销成本。
1.4 互斥量的原理
当我们使用互斥量之后,临界区的代码对于其他线程来说,只有两种状态:加锁与解锁,这就保证了临界区的原子性。而我们要知道锁本身就是能被所有执行流访问的资源,所以锁本身也是一种临界资源,当然也需要保证其原子性,所以锁本身实现就是原子的。
为了实现互斥锁操作,大多数体系结构都提供了 swap
或 exchange
指令,该指令的作用就是把寄存器和内存单元的数据相交换,以下就是实现加锁 lock
与解锁 unlock
的伪代码:
我们首先可以认为 mutex
的初始值为1,al
是计算机中的一个寄存器,当线程申请锁时,需要执行以下步骤:
- 先将
al
寄存器中的值清0。该动作可以被多个线程同时执行,因为每个线程都有自己的一组寄存器(上下文信息),执行该动作本质上是将自己的al
寄存器清0。 - 然后交换
al
寄存器和mutex
中的值。xchgb
是体系结构提供的交换指令,该指令可以完成寄存器和内存单元之间数据的交换。
- 最后判断
al
寄存器中的值是否大于0。若大于0则申请锁成功,此时就可以进入临界区访问对应的临界资源;否则申请锁失败需要被挂起等待,直到锁被释放后再次竞争申请锁。
我们需要注意的是CPU内的寄存器不是被所有的线程共享的,每个线程都有独自的一组寄存器,所以改变当前线程 al
寄存器的值并不会影响其他线程的 al
寄存器, 当然内存中的数据因为属于同一个进程,所以各个线程是共享的。
而当线程释放锁时,需要执行以下步骤:
- 将内存中的
mutex
置回1,使得下一个申请锁的线程在执行交换指令后能够得到1。 - 唤醒等待
mutex
的线程,让它们继续竞争申请锁。
在线程释放锁的过程中,并没有将当前线程的 al
寄存器中的值清0,这不会造成任何影响,因为每次线程在申请锁时都会先将自己 al
寄存器中的值清0,再执行交换指令。
所以我们申请锁的本质就是执行 xchgb
这一条汇编指令,因为只有一条,所以只有已执行与未执行两种状态,具有原子性。
2. 线程安全
线程安全是指在多线程环境下,多个线程并发执行同一段代码时,不会出现不可预期的错误结果或数据不一致的情况。
常见线程不安全的情况有:
- 不保护共享变量的函数。
- 函数状态随着被调用,状态发生变化的函数。
- 返回指向静态变量指针的函数。
- 调用线程不安全函数的函数。
而线程安全的情况有:
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
- 类或者接口对于线程来说都是原子操作。
- 多个线程之间的切换不会导致该接口的执行结果存在二义性。
而可重入函数与线程安全的联系有:
- 函数是可重入的,那就是线程安全的。
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
而可重入函数与线程安全的区别有:
- 可重入函数是线程安全函数的一种。
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数的锁还未释放则会产生死锁,因此是不可重入的。
3. 死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。
比如说如果某一执行流连续申请了两次锁,就会陷入死锁状态。具体情况如下:当该执行流第一次申请锁时,通常会申请成功。然而,第二次申请锁时,由于此锁已经被该执行流自身持有,再次申请会失败,进而导致该执行流被挂起。而此时,这个锁在其自己手上,可它又处于被挂起的状态,根本没有机会去释放锁。这样一来,该执行流将永远无法被唤醒,从而处于死锁状态。
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <cstdio>
#include <cstdlib>
using namespace std;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void*Routine(void*args)
{
pthread_mutex_lock(&mutex);
pthread_mutex_lock(&mutex);
return nullptr;
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr,Routine,nullptr);
pthread_join(tid,nullptr);
return 0;
}
其中形成死锁的必要条件有以下四个:
- 互斥条件: 一个资源每次只能被一个执行流使用。
- 请求与保持条件: 一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件: 一个执行流已获得的资源,在未使用完之前,不能强行剥夺。
- 循环等待条件: 若干执行流之间形成一种头尾相接的循环等待资源的关系。
而为了避免死锁我们一般也可以从这几个角度思考:
- 破坏死锁的四个必要条件。
- 加锁顺序一致。
- 避免锁未释放的场景。
- 资源一次性分配。
除此之外,还有一些避免死锁的算法,常见的比如有死锁检测算法和银行家算法。
4. 线程同步
4.1 饥饿问题
线程饥饿指的是某些线程由于各种原因,一直无法获得足够的 CPU 时间来执行任务,从而处于长期等待或执行时间极少的状态。 产生线程饥饿的原因主要有以下几种:
- 高优先级线程抢占:如果系统中有高优先级的线程持续占用 CPU 资源,那么低优先级的线程就可能长时间得不到执行机会,从而导致饥饿。例如,在实时系统中,高优先级的实时任务可能会一直抢占低优先级的普通任务。
- 线程调度不公平:如果线程调度算法不合理或者存在缺陷,可能导致某些线程被不公平地对待,长期无法获得执行机会。比如某些调度算法可能偏向于某些特定类型的线程或者特定状态的线程。
- 资源竞争:当多个线程竞争有限的资源时,一些线程可能因为一直无法获得所需资源而被阻塞,从而无法执行。例如,多个线程竞争一个互斥锁,而某些线程总是在竞争中失败,就可能陷入饥饿状态。
线程饥饿会导致系统性能下降,部分任务无法及时完成,甚至可能使整个系统陷入停滞或出现不可预测的行为。为了解决线程饥饿问题,我们可以让线程与线程之间形成同步关系。
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题。
4.2 条件变量
条件变量是利用线程间共享的全局变量进行同步的一种机制,条件变量是用来描述某种资源是否就绪的一种数据化描述。其一般包含两个步骤:
- 一个线程等待条件变量的条件成立而被挂起。
- 另一个线程使条件成立后唤醒等待的线程。
4.3 条件变量的接口
4.3.1 初始化条件变量
我们可以使用pthread_cond_init
初始化互斥量,使用方法如下:
- 函数原型:int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
- 参数:
cond
:需要初始化的条件变量。attr
:初始化条件变量的属性,一般设置为nullptr
即可。
- 返回值:条件变量初始化成功返回0,失败返回错误码。
这种调用函数接口初始化条件变量的方式我们称为动态分配,除此之外,我们也能使用如下的方式进行初始化,我们将其称为静态分配。
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
4.3.2 销毁条件变量
我们可以使用pthread_cond_destory
销毁互斥量,使用方法如下:
- 函数原型:int pthread_cond_destroy(pthread_cond_t *cond);
- 参数:
mutex
:需要销毁的条件变量。- 返回值:成功返回 0,失败返回错误码。
- 使用
PTHREAD_COND_INITIALIZER
静态初始化的条件变量不需要销毁。
4.3.3 等待条件变量
当某个线程满足某个条件时,我们就可以将其至于条件变量下等待。
- 函数原型:int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
- 参数:
cond
:需要等待的条件变量。
mutex
:当前线程所处临界区对应的互斥锁。
- 返回值:成功返回 0,失败返回错误码。
4.3.4 唤醒等待
在满足某个条件之后,我们就可以使用以下两种即可,将等待队列中的线程唤醒。
- 函数原型:
- int pthread_cond_broadcast(pthread_cond_t *cond);
- int pthread_cond_signal(pthread_cond_t *cond);
- 参数:
cond
:需要唤醒的条件变量。- 返回值:成功返回 0,失败返回错误码。
其中 pthread_cond_signal()函数用于唤醒等待队列中的第一个线程。pthread_cond_broadcast()函数用于唤醒等待队列中的全部线程。
比如我们下面创建五个线程,然后将其放入等待队列,最后由主线程进行唤醒。
#include <iostream>
#include <cstdio>
#include <pthread.h>
#include <unistd.h>
using namespace std;
#include <string>
pthread_mutex_t mutex;
pthread_cond_t cond;
void *Routine(void *args)
{
pthread_detach(pthread_self());
string name = "thread " + to_string((uint64_t)args);
while (true)
{
pthread_mutex_lock(&mutex);
pthread_cond_wait(&cond, &mutex);
cout << name << " running..." << endl;
pthread_mutex_unlock(&mutex);
}
}
int main()
{
pthread_t tids[5];
pthread_mutex_init(&mutex, nullptr);
pthread_cond_init(&cond, nullptr);
for (uint64_t i = 0; i < 5; i++)
{
pthread_create(tids + i, nullptr, Routine, (void *)i);
}
while (true)
{
sleep(1);
pthread_cond_signal(&cond);
}
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);
return 0;
}
在调用<font style="color:rgb(28, 31, 35);">pthread_cond_wait</font>
函数时需要传入对应的互斥锁,原因如下:
当线程由于某些条件不满足而需要在特定条件变量下进行等待时,必须释放该互斥锁。这是因为如果不释放互斥锁,其他线程将无法获取该锁以进入临界区修改共享资源,从而无法改变条件使等待线程被唤醒。
当该线程被唤醒后,会接着执行临界区内的代码,这就要求该线程必须立即获得对应的互斥锁。这样设计确保了线程在被唤醒后能够安全地访问临界区,避免了多个线程同时进入临界区而导致的数据不一致和资源竞争问题。
4.4 条件变量使用规范
使用条件变量我们一般遵守以下规范,如果是等待条件变量,函数应该放在互斥量加锁与解锁之间,因为判断条件也是一种临界资源。
pthread_mutex_lock(&mutex);
while (条件为假)
pthread_cond_wait(cond, mutex);
//修改条件
pthread_mutex_unlock(&mutex);
同样唤醒操作也需要类似的操作。
pthread_mutex_lock(&mutex);
//条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);