目录
一、为什么需要线程互斥
二、线程互斥的必要性
三、票务问题举例(多个线程并发的操作共享变量引发问题)
四、互斥锁的用法
1.互斥锁的原理
2、互斥锁的使用
1、初始化互斥锁
2、加锁和解锁
3、销毁互斥锁(动态分配时需要)
五、使用互斥锁改进票务问题
六、可重入与线程安全
1、可重入(Reentrant)
2、线程安全(Thread Safety)
上篇文章我们讲解了线程的概念以及线程的基本操作:
Linux线程(一)初识线程这篇文章我们来讲解一下线程互斥的内容。
一、为什么需要线程互斥
当多个线程试图同时修改同一份数据时,可能会导致数据不一致、竞态条件等问题。
当两个或多个线程同时访问和修改同一个共享资源时,如果没有适当的同步控制,可能会导致数据处于不一致的状态。例如,一个线程正在读取某个变量的同时,另一个线程可能正在修改这个变量,最终结果可能既不是原始值也不是任何一个线程期望修改后的值,造成不可预料的行为。(后面会举例说明)
所以就引出了线程互斥 :
在Linux系统中,线程互斥是一种确保多个线程在访问共享资源时不会产生冲突的机制。这是通过使用互斥锁(Mutex)来实现的,它是防止并发执行线程同时进入临界区(即访问共享资源的代码段)的一种同步原语。
二、线程互斥的必要性
线程互斥是确保多线程环境下程序正确性、稳定性和可预测性的关键手段,通过限制对共享资源的同时访问,避免了并发执行可能引发的各种问题:
避免数据竞争(Data Race):当两个或多个线程同时访问和修改同一个共享资源时,如果没有适当的同步控制,可能会导致数据处于不一致的状态。例如,一个线程正在读取某个变量的同时,另一个线程可能正在修改这个变量,最终结果可能既不是原始值也不是任何一个线程期望修改后的值,造成不可预料的行为。
确保数据一致性:互斥机制确保了在任何时候,最多只有一个线程可以修改共享资源。这样可以保证每次对共享数据的修改都是完整且原子的,从而维护了数据的一致性。
预防竞态条件(Race Condition):竞态条件是指程序的输出依赖于非确定性的线程执行顺序。没有互斥锁,即使程序逻辑正确,由于线程调度的不确定性,也可能导致错误的结果。比如经典的“票务问题”,如果不使用互斥锁,多个线程同时减去票数可能会导致卖出超过实际存在的票数。
实现同步点:除了防止并发访问带来的问题,互斥锁还可以作为线程间的同步工具,用于控制线程执行的顺序。例如,一个线程可能需要等待另一个线程完成特定任务后才能继续执行。
保护资源的完整性:某些资源(如文件、数据库连接、硬件设备等)可能不支持同时访问,或者同时访问会导致错误或损坏。互斥锁确保这些资源在被一个线程使用时,其他线程不能访问,从而保护了资源的完整性。
三、票务问题举例(多个线程并发的操作共享变量引发问题)
我们来看以下代码,多个线程访问一个全局变量ticket来模拟抢票,ticket就是共享变量:
#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(1);
printf("%s sells ticket:%d\n",id,ticket);
ticket--;
}
else
{
break;
}
}
}
int main()
{
pthread_t t1,t2,t3,t4;
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);
return 0;
}
运行后发现
票数竟然出现了0和-1,显然是不符合预期的。
每个线程在检查
ticket
变量是否大于1后,直接进行减操作和打印,没有确保在这两个操作之间没有其他线程也进行了同样的检查和操作。这导致了多个线程可能几乎同时判断ticket
大于1,并都执行减1操作,造成票数卖超的错误。多个线程直接读写共享变量
ticket
而没有加锁保护,这违反了线程安全原则。当一个线程正在读取ticket
的值时,另一个线程可能正在修改它,导致读取到的是不一致或中间状态的数据。
load :将共享变量ticket从内存加载到寄存器中可能同时有几个线程判断了ticket>0,并进行了ticket--操作,但是这个时候ticket的值已经被其他线程修改,这个时候就造成了共享变量的数据错误。update : 更新寄存器里面的值,执行-1操作store :将新值,从寄存器写回共享变量ticket的内存地址
解决这些问题的关键是在访问共享资源(这里是ticket变量)之前使用互斥锁(Mutex),确保同一时间只有一个线程能执行临界区内的代码,从而避免了数据竞争和竞态条件,确保了线程安全。
四、互斥锁的用法
1.互斥锁的原理
加锁(Lock):当一个线程想要进入临界区时,它会尝试获取互斥锁。如果锁未被其他线程持有,该线程将成功获取锁并进入临界区。
解锁(Unlock):完成对共享资源的操作后,线程会释放互斥锁,允许其他等待中的线程有机会获取锁并访问资源。
2、互斥锁的使用
在Linux中,使用POSIX线程库(pthread)来处理线程和互斥锁。以下是基本的使用步骤:
1、初始化互斥锁
静态初始化:pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
动态初始化:int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
参数:
mutex:要初始化的互斥量
attr:NULL
2、加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号
互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
3、销毁互斥锁(动态分配时需要)
int pthread_mutex_destroy(pthread_mutex_t *mutex);
五、使用互斥锁改进票务问题
通过在访问和修改ticket
变量前后分别调用pthread_mutex_lock()
和pthread_mutex_unlock()
,确保了在任何时刻只有一个线程能进行售票操作,从而解决了线程间的数据竞争问题,保证了票数的准确减少,避免了超卖现象。
代码示例:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.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--;
}
else
{
// 释放锁并跳出循环
pthread_mutex_unlock(&mutex);
break;
}
pthread_mutex_unlock(&mutex);
}
}
int main()
{
// 初始化互斥锁
pthread_mutex_init(&mutex, NULL);
pthread_t t1,t2,t3,t4;
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);
return 0;
}
运行后发现保证了票数的准确减少,避免了超卖现象。
六、可重入与线程安全
1、可重入(Reentrant)
定义:可重入指的是一个函数或一段代码可以在任意时刻被中断,然后再次进入并正确执行,即使在之前调用还未完成的情况下也是如此。对于可重入代码,最重要的是它的内部状态不会因多次调用而受损,且不依赖于外部状态或存储。
特点:
- 不使用静态或全局变量存储状态信息。
-
不使用用malloc或者new开辟出的空间
- 如果必须使用全局数据,那么这些数据必须是只读的或能以线程安全的方式修改。
- 函数不依赖于任何外部资源的状态,或能确保外部资源访问的线程安全性。
- 递归调用是可重入的一个特例。
2、线程安全(Thread Safety)
定义:线程安全指多个线程同时访问(包括读取和写入)同一段代码或数据时,仍然能够保持正确的执行结果,不会引发数据不一致、崩溃或其他未定义行为。这意味着代码需要采取适当的同步措施(如互斥锁、信号量等)来防止数据竞争和竞态条件。
特点:
- 通过同步机制确保共享资源的访问是互斥的,防止数据竞争。
- 可能通过加锁机制来实现,但这也会引入潜在的死锁和性能开销。
- 线程安全的代码在多线程环境下不需要外部干预即可安全运行。
关系:
- 交集:可重入代码通常是线程安全的,因为它不依赖于全局状态,减少了并发访问的冲突点。
- 区别:并非所有线程安全的代码都是可重入的。例如,一个使用了锁来保护共享资源的函数,虽然线程安全(因为一次只有一个线程可以修改资源),但如果在锁内调用自己(递归调用),可能会导致死锁,因此不是可重入的。