目录
多线程抢票问题
临近五一小长假,大多数人想着去外地进行旅游或者回趟老家;出行大多都会选择火车、动车等等,由于全国的出行人数众多;铁路局会在出行前两星期进行放票,大家在某一时刻进行抢票。这个看似简单的现实问题其中蕴含着很大的学问,我们可以把抢票的每个人看成一个线程,把票数看成整个进程中的公共资源。当满足票数大于零时,线程一直对这个资源进行瓜分;但是在实现代码后,我们会发现非常奇怪的问题。
我们可以编写一个简单的程序,模拟实现以下这个过程。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
//定义和初始化锁
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;
}
}
}
int main(void)
{
pthread_t t1, t2, t3, t4;
//局部初始化
pthread_mutex_init(&mutex, NULL);
pthread_create(&t1, NULL, route, "thread 1");
pthread_create(&t2, NULL, route, "thread 2");
pthread_create(&t3, NULL, route, "thread 3");
pthread_create(&t4, NULL, route, "thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
//局部锁的销毁
pthread_mutex_destroy(&mutex);
}
编译运行这个程序我们会发现一个奇怪的问题,最后某几个线程得到的票为负数;这个现象非常的不合理。
对问题的解释
代码的原子性
要解释这个现象我们还要知道代码的原子性 ;"代码的原子性"通常指的是代码的不可分割性或原子性操作 。原子性操作是指在执行过程中不可中断的操作,要么全部执行成功,要么全部不执行 。或者简单来说就是要么执行成功,要么执行失败。
原子性操作对于并发编程和多线程环境特别重要,因为在这些情况下,多个线程可能同时访问共享资源。如果操作不是原子性的,那么可能会出现竞态条件(Race Condition),导致数据不一致或其他问题。当然这只是一个概率巧合问题,问题虽小应用在现实生活中仍然是不安全的。
我们以编程语言中"++"操作符为例配合两个线程给大家做以详细的解释;++操作符看似是一句简单的语句,但是代码执行时形成汇编语言会转化为三条汇编语句。
首先定义的变量i实在内存中的,对内存中的数据进行操作时先将这个数据从内存转移到CPU中,在CPU中会对这个变量进行运算操作,最后运算操作完成时再将数据从CPU转移到内存中。
但是对于多线程来说会并发访问这个数据,然而每个线程在CPU中都有固定的调度时间,当某一个线程的调度时间到达时可能正处于这三条语句中的某一条语句,每个线程有含有单独的栈空间,线程调度切换时会将寄存器中产生的临时变量和上下文保存,当再次调度这个线程时会将上次保存的数据交给寄存器。因此,++操作符不是原子的,多线程并发访问会导致数据不一致。
**注:**我们可以将只含有一条汇编语句认为是原子的;
那么,对于判断语句是原子的还是非原子的呢?
很显然是非原子的,判断语句包含两步,第一步会先将数据转移到CPU中进行判断,第二步会返回比较结果。
因此对于上面的抢票代码我们就可以做出很好的解释(以两个线程为例):当票数为1时,主线程将票数转移到CPU中进行判断,判断成功;当要返回判断结果时,恰好这个主线程的调度时间到了,保存临时数据和上下文切换新线程;新线程切换成功后,又进行同样的操作,将票数转移到CPU中进行判断,判断成功;恰好这个新线程的调度时间也到了又且回到主线程;主线程将上次的临时数据和上下文交给CPU进行处理,因为上次已经判断成功了,就将上次判断成功进行返回,将票数读到CPU中进行操作,然后对票数减一,将票数转移到内存中此时票数已经为零,主线程操作结束切换新线程;新线程又将自己上次的临时数据和上下文交给CPU,上次已经判断成功,又将上次返回。然后又从内存中读取已经为0的票数进行操作,这样我们就能得到上面的现象。
线程互斥
上述问题的解决方法
要想解决上面的问题就要做到以下三点:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
相关概念
- 临界资源:多线程执行流共享的资源就叫做临界资源(上面的代码中我们的票数就是临界资源)
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区(上面的代码中对票数的判断和获取票数就是临界区)
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
互斥量(锁)
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
- 多个线程并发的操作共享变量,会带来一些问题
锁的定义和初始化
//全局初始化(静态分配)
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
//局部初始化(动态分配)
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict
attr);
参数:
- mutex:要初始化的互斥量
- attr:锁的属性,一般为空指针
锁的销毁
- 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
- 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
//销毁函数
int pthread_mutex_destroy(pthread_mutex_t *mutex);
加锁和解锁
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>
int ticket = 100;
//定义锁
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;
}
}
}
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;
}
对线程加锁后,将临界资源保护起来,多线程并发访问临界资源变成了串行访问临界资源,每个线程都互斥,这样减少了程序运行的效率却提高了程序的安全性。
加锁注意事项
- 我们要尽可能的给少的代码块加锁
- 一般加锁,都是给临界区加锁
使用锁注意事项
- 锁虽然也是公共资源,但是申请锁本身是安全的;因为申请锁是原子的,要么申请成功,要么申请失败。
- 申请使用锁时不要给某个线程搞特殊不加锁,因为这样依然会又概率面临上面程序产生的问题。
- 根据互斥的定义任何时刻,任何时刻只允许一个线程申请锁成功。申请锁失败的线程,会被阻塞等待。
- 一个线程临界区访问临界资源的时候,也可能发生切换;切换后的线程将不会申请到锁,因此就不会在临界区访问临界资源。
- 个别系统中上面的代码运行后会有只有一个线程获取到了所有的票,表示这个线程对锁的竞争能力比较强,这个问题为多线程饥饿 ,要让线程在执行是具有一定的顺序。(线程同步)
锁的原理
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,**该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,**保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
可重入与线程安全
概念
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
常见线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
常见线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
常见可重入情况
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全的关系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
可重入与线程安全的区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
今天对Linux下线程互斥和锁的分享到这就结束了,希望大家读完后有很大的收获,也可以在评论区点评文章中的内容和分享自己的看法;个人主页还有很多精彩的内容。您三连的支持就是我前进的动力,感谢大家的支持!!!