线程互斥
线程互斥是多线程编程中的一种同步机制,它用于确保在同一时刻只有一个线程能够访问共享资源或临界区。互斥的目的是防止多个线程同时修改共享数据,从而避免数据竞争和不确定的行为。互斥通常使用互斥锁(Mutex)来实现。互斥锁有两个主要操作:加锁(Lock)和解锁(Unlock)。当一个线程希望访问共享资源时,它尝试锁定互斥锁。如果互斥锁当前没有被其他线程锁定,那么该线程将成功锁定互斥锁,获得访问权限。如果互斥锁已经被其他线程锁定,那么请求锁的线程将被阻塞,直到互斥锁被释放。当一个线程完成对共享资源的访问后,它解锁互斥锁,使其他线程有机会获得访问权限。
互斥锁确保了临界区中的代码只能由一个线程执行,从而避免了竞态条件和数据不一致性问题。这对于多线程环境中的并发编程非常重要,因为如果多个线程同时修改共享数据,可能会导致不可预测的结果。
相关概念
临界资源:指在多线程编程中被多个线程共享的数据或资源,例如内存空间、文件、数据库连接等。当多个线程同时访问临界资源时,可能会导致数据不一致或者不可预测的结果,因此需要通过同步机制来保护这些临界资源。
临界区:指的是一段代码或程序块,其中包含了对临界资源的访问或修改操作。临界区用于确保在多线程环境中,同一时间只有一个线程可以执行这段代码,以避免多个线程同时访问临界资源导致的竞态条件和数据不一致问题。
原子性:指的是一个操作或一组操作要么完全执行,要么完全不执行,不会出现部分执行的情况。在多线程编程中,原子性是一种重要的属性,用于确保多线程环境下的数据操作是不可分割的,不会受到其他线程的干扰。
互斥量
互斥量(Mutex,全名Mutual Exclusion)是一种常用的同步机制,用于在多线程编程中保护临界资源,以确保在任何给定时间内只有一个线程可以访问临界资源。互斥量本身是一种原子操作,它提供了互斥性,即一次只允许一个线程进入被保护的临界区。互斥量通常有两种状态:锁定状态和解锁状态。当一个线程进入互斥量保护的临界区时,互斥量会被锁定,这意味着其他线程必须等待,直到互斥量被解锁。一旦线程离开临界区,互斥量将被解锁,允许其他线程进入。
因此互斥量的使用有助于防止竞态条件,确保数据的一致性,以及避免多线程程序中的冲突。如下代码:
int tickets = 100;
void* thread_run(void *args)
{
char* name = static_cast<char*>(args);
while(tickets > 0)
{
usleep(10000); //模拟抢票的业务处理
cout << name <<" tickets : " << tickets-- << endl;
}
return nullptr;
}
int main()
{
pthread_t t1, t2, t3;
pthread_create(&t1, nullptr, thread_run, (void*)"thread1");
pthread_create(&t2, nullptr, thread_run, (void*)"thread2");
pthread_create(&t3, nullptr, thread_run, (void*)"thread3");
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
return 0;
}
在上述代码中创建了3个线程,都去执行 thread_run 方法,当tickets大于0的时候,就继续进去抢票。
从结果可以看到,最后票数都抢到了-1,这结果显然就不合理了,这是因为在多线程中,当判断票数大于0时进入,在usleep模拟数据处理期间可能会切换到其他线程,其他线程也会进行判断。因此可能会有多个线程进入,最后都对tickets进行减操作。想要解决这个问题,就需要满足以下三个条件:
- 代码就必须要有互斥行为,即当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
而互斥量刚好就满足上述要求。
互斥量接口函数
初始化互斥量
初始化互斥量有两种方法,分别为静态初始化和动态初始化。静态初始化是最简单的方式,可以在定义互斥量时进行初始化。使用PTHREAD_MUTEX_INITIALIZER
宏或者pthread_mutex_t
结构体的默认值进行初始化。例如:
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
这个方法会自动初始化互斥量,但要注意,一旦你使用这种方式初始化,就不能再次调用pthread_mutex_init或pthread_mutex_destroy。
动态初始化使用pthread_mutex_init函数来在运行时动态初始化互斥量。这种方式允许你更多的控制,如设置互斥量属性,适用于更复杂的场景。pthread_mutex_init 是一个函数,用于在 C/C++ 程序中初始化一个 pthread 互斥量,以便在多线程环境中控制对共享资源的访问。函数原型如下:
其中参数 mutex 是一个指向 pthread_mutex_t 类型的指针,用于指定要初始化的互斥量。
attr 是一个指向 pthread_mutexattr_t 类型的指针,通常设置为 NULL,表示使用默认属性。你可以使用属性对象来自定义互斥量的行为,如设置它是递归互斥量等。
使用哪种初始化方法取决于你的需求。如果只需要简单的互斥量,静态初始化可能更方便,但对于更复杂的情况,动态初始化提供了更多的选项。
销毁互斥量
如果是静态初始化的互斥量就不需要进行销毁,但如果使用动态初始化,需要在之后调用pthread_mutex_destroy来释放互斥量的资源。pthread_mutex_destroy 也是一个函数,用于销毁(释放)已经初始化的 pthread 互斥量,以便释放相关资源并确保不再使用该互斥量。这个函数的原型如下:
其中参数 mutex 是一个指向 pthread_mutex_t 类型的指针,用于指定要销毁的互斥量。
注意:不要销毁一个已经加锁的互斥量,并且对一个已经销毁的互斥量,要确保后面不会有线程再尝试加锁。
互斥量加锁和解锁
要给互斥量进行加锁可以使用 pthread_mutex_lock 函数来加锁互斥量,而给互斥量进行解锁可以使用 pthread_mutex_unlock 函数,这是在多线程环境中保护共享资源的常见做法。这两个函数的原型如下:
其中 pthread_mutex_lock 函数的参数 mutex 是一个指向 pthread_mutex_t 类型的指针,它表示要锁定的互斥量。pthread_mutex_unlock 函数的参数 mutex 也是一个指向 pthread_mutex_t 类型的指针,它表示要解锁的互斥量。
在多线程编程中,通常遵循以下步骤使用这几个函数:
-
初始化互斥量:在使用互斥量之前,需要初始化它。这通常通过 pthread_mutex_init 函数来完成。
-
加锁:在需要互斥访问共享资源的代码段前,使用 pthread_mutex_lock 来获取互斥量的锁。如果其他线程已经锁住了该互斥量,当前线程会被阻塞,直到互斥量可用。
-
访问共享资源:一旦获得互斥量的锁,线程可以安全地访问共享资源。
-
解锁:在完成对共享资源的访问后,使用 pthread_mutex_unlock 来释放互斥量的锁,以允许其他线程获取锁并继续访问共享资源。
基于互斥量的抢票测试
接下来就可以用互斥量来对之前的抢票代码进行加锁修改,如下代码:
int tickets = 100;
mutex mtx;
void *thread_run(void *args)
{
char *name = static_cast<char *>(args);
while (1)
{
mtx.lock();
if (tickets > 0)
{
usleep(10000);
cout << name << " tickets : " << tickets-- << endl;
mtx.unlock();
}
else
{
mtx.unlock();
break;
}
}
return nullptr;
}
int main()
{
pthread_t t1, t2, t3;
pthread_create(&t1, nullptr, thread_run, (void *)"thread1");
pthread_create(&t2, nullptr, thread_run, (void *)"thread2");
pthread_create(&t3, nullptr, thread_run, (void *)"thread3");
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
return 0;
}
死锁
死锁是多线程或多进程编程中一种常见的问题,它发生在各个线程或进程相互等待对方释放资源的情况下,导致它们无法继续执行。这是一个相互阻塞的状态,每个线程都在等待其它线程释放资源,但没有一个线程能够继续执行。死锁通常涉及多个互斥锁的使用,而线程之间的操作顺序不当会导致这种情况。以下是一些可能导致死锁的常见情形:
-
互斥锁的循环等待: 多个线程以不同的顺序请求多个锁,从而在等待对方释放锁时导致死锁。
-
资源不足: 当多个线程都在等待获取某些有限资源,而这些资源被其他线程占用时,可能导致死锁。
-
不可中断的等待: 如果线程在等待资源时不能被中断,且资源长时间不释放,可能导致死锁。
因此在我们的多线程编程中避免死锁是非常重要的,一些常见的避免死锁策略有:
-
按照相同的顺序获取锁: 所有线程都按照相同的顺序获取锁,从而减少死锁的风险。
-
使用超时: 设置获取锁的超时时间,如果超过一定时间仍未成功获取锁,线程可以执行回退操作。
-
避免循环等待: 设计和组织程序,以确保循环等待的情况不会发生。
-
使用互斥锁的最小持有时间: 减少持有锁的时间,以降低死锁的风险。
解决死锁通常需要深入理解多线程编程和锁的工作原理,以确保线程之间的同步和资源分配不会导致潜在的死锁情况。如果出现死锁,通常需要分析问题的根本原因,并对程序进行重新设计或改进以避免它。
可重入与不可重入函数
可重入函数和不可重入函数是两种不同类型的函数,根据它们在多线程环境中的行为而定义。可重入函数是指在多线程环境中能够安全调用的函数,这意味着,即使在一个函数被多个线程同时调用时,它仍然能够正确执行而不导致数据竞争或死锁。不可重入函数是指在多线程环境中不安全调用的函数,当多个线程同时尝试调用不可重入函数时,可能会导致数据竞争、竞态条件、死锁等问题。
可重入函数通常满足以下条件:
- 不使用全局变量或静态变量,而是使用局部变量或传递参数。
- 不依赖于外部资源,如文件或共享内存。
- 不调用不可重入函数,因为这可能会导致数据竞争或死锁。
不可重入函数通常具有以下特点:
- 使用全局变量或静态变量。
- 使用外部资源,如文件操作,而不是线程安全的方式。
- 依赖于共享状态,而不是参数传递。
例如标准C库中的 rand() 函数通常是一个不可重入函数,因为它使用了全局状态。而多个线程同时调用 rand() 可能导致不确定的结果。相反,rand_r() 函数是一个可重入版本,因为它允许你传递一个状态参数,从而避免全局状态的竞争。所以在多线程编程中,尽量使用可重入函数,或者在使用不可重入函数时要格外小心,采取适当的同步措施来确保线程安全。可重入函数的使用有助于减少竞态条件和提高多线程程序的稳定性。
可重入与线程安全
"可重入"和"线程安全"都是与多线程编程相关的概念,它们之间有一些联系,但又不完全相同。可重入是一个函数或代码段的特性,表示它可以安全地在多个线程中同时执行,而不会导致不一致性或竞争条件。可重入函数通常不会使用全局变量,或者如果使用全局变量,会进行适当的同步控制以确保线程安全。可重入函数的典型特征是,它们不会修改共享的状态,或者如果修改了,会使用互斥锁等机制来确保安全。而线程安全是一种更广泛的概念,表示一个类、对象或函数在多线程环境下可以安全使用,而不会导致数据竞争或不一致性。线程安全的代码可以包括可重入函数,但它的范围更广泛,可以包括多种策略,如互斥锁、信号量、读写锁等,以确保共享资源的安全访问。
因此,可以说可重入函数是线程安全的一种特例,而线程安全包括更广泛的概念和解决方案,以确保整个程序或系统在多线程环境下能够正确运行。要使代码线程安全,通常需要考虑共享数据的同步、互斥访问和竞争条件等方面的问题,而不仅仅是函数的可重入性。
总结
文章中介绍了Linux中线程互斥的一些基本概念以及互斥量的接口函数的使用,并基于简易的模拟抢票代码对加锁和未加锁的两种现象进行分析。对加锁产生的死锁的原因进行分析并对产生的原因进行破坏,即避免死锁。最后对可重入函数和不可重入函数进行分析阐述,以及其与线程安全的关系进行分析。总的来说,互斥锁可以确保多线程应用程序能够正确、安全地访问共享资源,但是我们需要谨慎去避免加锁后可能会产生的死锁问题。
码文不易,如果文章对你有帮助的话,就劳烦客官给作者来一个大大👍呗!