文章目录
- Linux线程互斥
- 线程互斥相关概念
- 互斥量mutex
- 引出线程并发问题
- 引出互斥锁、互斥量
- 互斥量的接口
- 初始化互斥量
- 销毁互斥量
- 互斥量加锁和解锁
- 使用互斥锁抢票
- 可重入和线程安全
- 概念:
- 常见线程不安全的情况
- 常见线程安全的情况
- 常见不可重入的情况
- 常见可重入情况
- 可重入与线程安全联系
Linux线程互斥
线程互斥(Mutual Exclusion)是多线程编程中的一个重要概念,用于解决多个线程同时访问共享资源时可能产生的竞争条件(Race Condition)和数据不一致问题。
在深入探索Linux线程互斥之前先了解一些基本概念
线程互斥相关概念
- 临界资源:多线程执行流共享的资源就叫做临界资源
- 临界区:每个线程内部访问临界资源的代码片段,就叫做临界区
- 互斥:任何时刻,互斥保证只有一个执行流进入临界区,访问临界资源,是保障临界资源安全的重要手段
- 原子性:跟MySQL中事务的原子性一样,如果某种操作只有要么完成要么没完成两种状态,我们就称这种操作是原子性的。在这里原子性保证线程的某种操作不会被任何调度机制打断。
- 竞争条件:多个线程一起访问同一个共享资源,可能会导致不确定的结果。线程之间的这种关系就成为竞争,这种现象就称为竞争条件。
互斥量mutex
下面来看一个经典的抢票样例,代码如下:
// 操作共享变量会有问题的售票系统代码
#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;
}
}
return arg;
}
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);
}
全局变量ticket作为临界资源被多个线程同时访问,看看代码的运行结果:
- 结果片段1
- 结果片段二
引出线程并发问题
根据以上代码,我们提出以下疑问:
- 为什么多个线程会抢到同一张票?
- 为什么票数会出现负数?
我们来看上面的代码中的核心片段:
if (ticket > 0)
{
usleep(1000);
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
}
- 在if判断结束之后,代码可以并发的切换到其它的线程,这样一来,很有可能当前线程还没有执行
ticket--
这个操作,其它线程就进来了。这就是为什么会有多个线程抢到同一张票的原因。 - 同样的,多个线程抢到同一张票,并依次执行
ticket--
,最终出现票数为负数的情况。简单来说就是,if判断
与ticket--
,两个操作中间并不是“无缝的”。 ticket--
操作本身就不是一个原子操作,--
操作并非是一条汇编语句,而是三条汇编语句的集合。load
:将共享变量ticket从内存加载到寄存器中update
: 更新寄存器里面的值,执行-1操作store
:将新值,从寄存器写回共享变量ticket的内存地址
要解决上面线程并发带来的问题需要做到以下几点:
- 线程访问临界区必须是互斥的,即一个线程访问临界区时其它线程阻塞。
- 如果线程没有访问临界区,那么该线程不能阻止其它线程进入临界区。
引出互斥锁、互斥量
要想做到一个线程访问临界区时阻止其它线程访问,我们就需要使用到互斥锁机制。锁,顾名思义就是锁上临界区不让其它线程进去。而实现互斥锁机制就需要用到互斥量。这里需要注意互斥锁和互斥量两个名词的区分:
- 互斥量是一个数据结构或对象,通常包含了锁的状态信息,是否被锁定等
- 互斥锁是互斥量的操作机制,通过互斥锁操作,线程可以在进入临界区之前锁定互斥量,退出临界区时解锁互斥量。这也对应着我们常说的上锁和解锁两个操作。
为了方便阐述,后面出现的锁和互斥量不做过多区分。
上图中表示使用互斥锁实现线程互斥的示意图,lock
表示上锁(),上锁之后其他线程不能访问临界区,unlock
表示解锁,解锁之后其他线程才可以继续申请上锁。下面解释上锁是原子操作的原理:
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 现在我们把lock和unlock的伪代码改一下
加锁的具体步骤:
-
初始化互斥锁,初始化通常在声明时完成
-
申请获取锁:
- 检查锁的状态,如果是空闲状态则继续执行锁操作,否则就阻塞等待
- 在汇编层面上使用xchgb指令(作用和swap一样),交换寄存器和内存中的互斥量状态数据,假设置1,使得申请到锁之后该线程能访问临界资源。而由于此时内存中互斥量的状态为0,其它线程无法申请锁就失败了。
-
访问临界区
-
释放锁
互斥量的接口
初始化互斥量
- 静态分配:
#include <pthread.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
- 动态分配:
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
其中pthread_mutex_t *mutex
:指向需要初始化的互斥锁对象的指针。
const pthread_mutexattr_t *attr
:指向互斥锁属性对象的指针。如果传入NULL,则使用默认属性。
销毁互斥量
如果要销毁一个互斥量可以执行以下代码:
#include <pthread.h>
int pthread_mutex_destroy(pthread_mutex_t *mutex);
注意:
- 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁(在栈上分配,自动销毁)
- 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex); //加锁
int pthread_mutex_unlock(pthread_mutex_t *mutex); //解锁
对某个互斥量进行加锁或者解锁,成功返回0,失败返回错误码
具体的,调用pthread_lock时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁住
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么
pthread_ lock
调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
使用互斥锁抢票
了解了互斥的原理以及相关的操作之后,我们可以对上面的样例代码做出以下改进:
#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);//访问结束之后释放锁
// sched_yield(); 放弃CPU
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
}
return arg;
}
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);//主动销毁互斥量
}
观察结果:
可重入和线程安全
概念:
- 线程安全问题:多个线程访问同一段代码的时候出现数据不一致问题。常见对全局变量或者是静态变量进行操作,并且没有锁保护的情况就会出现线程安全问题。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他执行流再次进入(线程并发执行导致),我们称这种现象叫重入。一个函数在重入的情况下,运行结果不会出现任何数据不一致问题或者是其他问题,则该函数就被称为可重入函数。否则就是不可重入函数。
常见线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化
- 返回指向静态变量指针的函数
- 调用线程不安全的函数
常见线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见不可重入的情况
-== 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的==
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
常见可重入情况
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
可重入与线程安全联系
- 函数是可重入的,那就是线程安全的
- 函数不可重入不代表一定会引发线程安全问题
- 如果一个函数中有全局变量且尝试修改,那么这个函数既不是可重入,也不是线程安全的
- 可重入函数是线程安全函数的一种