目录
多线程抢票问题
对问题的解释
代码的原子性
线程互斥
上述问题的解决方法
相关概念
互斥量(锁)
锁的定义和初始化
锁的销毁
加锁和解锁
加锁注意事项
使用锁注意事项
锁的原理
可重入与线程安全
概念
常见线程不安全的情况
常见线程安全的情况
常见不可重入的情况
常见可重入情况
可重入与线程安全的关系
可重入与线程安全的区别
多线程抢票问题
临近五一小长假,大多数人想着去外地进行旅游或者回趟老家;出行大多都会选择火车、动车等等,由于全国的出行人数众多;铁路局会在出行前两星期进行放票,大家在某一时刻进行抢票。这个看似简单的现实问题其中蕴含着很大的学问,我们可以把抢票的每个人看成一个线程,把票数看成整个进程中的公共资源。当满足票数大于零时,线程一直对这个资源进行瓜分;但是在实现代码后,我们会发现非常奇怪的问题。
我们可以编写一个简单的程序,模拟实现以下这个过程。
#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 时,可能会遇到以下情况:
对抢票代码的改进:
#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下线程互斥和锁的分享到这就结束了,希望大家读完后有很大的收获,也可以在评论区点评文章中的内容和分享自己的看法;个人主页还有很多精彩的内容。您三连的支持就是我前进的动力,感谢大家的支持!!!