目录
资源竞争(背景)
锁(解决方式,实现同步)
互斥锁
读写锁
自旋锁
资源竞争(背景)
竞态条件
当多个线程并发访问和修改同一个共享资源(如全局变量)时,如果没有适当的同步措施,就会遇到线程同步问题。这种情况下,程序最终的结果依赖于线程执行的具体时序,导致了竞态条件。
竞态条件(race condition)是一种特定的线程同步问题,指的是两个或者以上进程或者线程并发执行时,其最终的结果依赖于进程或者线程执行的精确时序。它会导致程序的行为和输出超出预期,因为共享资源的最终状态取决于线程执行的顺序和时机。为了确保程序执行结果的正确性和预期一致,需要通过适当的线程同步机制来避免竞态条件。
#include<stdlib.h>
#include<stdio.h>
#include<unistd.h>
#include<pthread.h>
#define COUNT 20000
long long int num = 0;
void* fun(void *arg) {
for (int i = 0;i < 1000000;i++) { // 加100w
num++;
}
}
int main(int argc, char const* argv[])
{
pthread_t tid[2];
pthread_create(&tid[0], NULL, fun, NULL);
pthread_create(&tid[1], NULL, fun, NULL);
// 等待全部线程执行完成
for (int i = 0;i < 2;i++) {
pthread_join(tid[i], NULL);
}
printf("num的值是:%lld\n", num);
return 0;
}
两个线程使用同一个资源,出现资源竞争问题。
锁(解决方式,实现同步)
如何避免竞态条件
上述程序如果想避免竞态条件,有下面两种解决方案:
- 避免多线程写入一个地址。
- 给资源加锁,使同一时间操作特定资源的线程只有一个。
方法1可以通过逻辑上组织业务逻辑实现,这里我们讲方法2。
想解决竞争问题,我们需要互斥锁——mutex。
常见的锁机制
锁主要用于互斥,即在同一时间只允许一个执行单元(进程或线程)访问共享资源。包括上面的互斥锁在内,常见的锁机制共有三种:
- 互斥锁(Mutex):保证同一时刻只有一个线程可以执行临界区的代码。
- 读写锁(Reader/Writer Locks):允许多个读者同时读共享数据,但写者的访问是互斥的。
- 自旋锁(Spinlocks):在获取锁之前,线程在循环中忙等待,适用于锁持有时间非常短的场景,一般是Linux内核使用。
互斥锁
pthread_mutex_t 是一个定义在头文件<pthreadtypes.h>中的联合体类型的别名,其声明如下。
typedef union
{
struct __pthread_mutex_s __data;
char __size[__SIZEOF_PTHREAD_MUTEX_T];
long int __align;
} pthread_mutex_t;
pthread_mutex_t用作线程之间的互斥锁。互斥锁是一种同步机制,用来控制对共享资源的访问。在任何时刻,最多只能有一个线程持有特定的互斥锁。如果一个线程试图获取一个已经被其他线程持有的锁,那么请求锁的线程将被阻塞,直到锁被释放。
用途
- 保护共享数据,避免同时被多个线程访问导致的数据不一致问题。
- 实现线程间的同步,确保线程之间对共享资源的访问按照预定的顺序进行。
操作
- 初始化(pthread_mutex_init):创建互斥锁并初始化。
- 锁定(pthread_mutex_lock):获取互斥锁。如果锁已经被其他线程持有,调用线程将阻塞。
- 尝试锁定(pthread_mutex_trylock):尝试获取互斥锁。如果锁已被持有,立即返回而不是阻塞。
- 解锁(pthread_mutex_unlock):释放互斥锁,使其可被其他线程获取。
- 销毁(pthread_mutex_destroy):清理互斥锁资源。
#include <pthread.h>
/**
* @brief 获取锁,如果此时锁被占则阻塞
*
* @param mutex 锁
* @return int 获取锁结果
*/
int pthread_mutex_lock(pthread_mutex_t *mutex);
/**
* @brief 非阻塞式获取锁,如果锁此时被占则返回EBUSY
*
* @param mutex 锁
* @return int 获取锁结果
*/
int pthread_mutex_trylock(pthread_mutex_t *mutex);
/**
* @brief 释放锁
*
* @param mutex 锁
* @return int 释放锁结果
*/
int pthread_mutex_unlock(pthread_mutex_t *mutex);
pthread_mutex_lock
该函数用于锁定指定的互斥锁。如果互斥锁已经被其他线程锁定,调用此函数的线程将会被阻塞,直到互斥锁变为可用状态。这意味着如果另一个线程持有锁,当前线程将等待直到锁被释放。
成功时返回0;失败时返回错误码。
pthread_mutex_trylock
该函数尝试锁定指定的互斥锁。与pthread_mutex_lock不同,如果互斥锁已经被其他线程锁定,pthread_mutex_trylock不会阻塞调用线程,而是立即返回一个错误码(EBUSY)。
如果成功锁定互斥锁,则返回0;如果互斥锁已被其他线程锁定,返回EBUSY;其他错误情况返回不同的错误码。
pthread_mutex_unlock
该函数用于解锁指定的互斥锁。调用线程必须是当前持有互斥锁的线程;否则,解锁操作可能会失败。
成功时返回0;失败时返回错误码。
初始化互斥锁
PTHREAD_MUTEX_INITIALIZER是POSIX线程(Pthreads)库中定义的一个宏,用于静态初始化互斥锁(mutex)。这个宏为互斥锁提供了一个初始状态,使其准备好被锁定和解锁,而不需要在程序运行时显式调用初始化函数。
当我们使用PTHREAD_MUTEX_INITIALIZER初始化互斥锁时,实际上是将互斥锁设置为默认属性和未锁定状态。这种初始化方式适用于简单的同步问题,我们可以通过以下代码初始化互斥锁。
static pthread_mutex_t counter_mutex = PTHREAD_MUTEX_INITIALIZER;
案例(对上面那个资源竞争的代码稍作修改):
static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void* fun(void* arg) {
for (int i = 0;i < 1000000;i++) { // 加100w
// 获取锁
pthread_mutex_lock(&mutex);
num++;
// 释放锁
pthread_mutex_unlock(&mutex);
}
}
读写锁
读操作:在读写锁的控制下,多个线程可以同时获得读锁。这些线程可以并发地读取共享资源,但它们的存在阻止了写锁的授予。
写操作:如果至少有一个读操作持有读锁,写操作就无法获得写锁。写操作将会阻塞,直到所有的读锁都被释放。
pthread_rwlock_t
typedef union { struct __pthread_rwlock_arch_t __data; char __size[__SIZEOF_PTHREAD_RWLOCK_T]; long int __align; } pthread_rwlock_t;
pthread_rwlock_init()
/** * @brief 为rwlock指向的读写锁分配所有需要的资源,并将锁初始化为未锁定状态。读写锁的属性由attr参数指定,如果attr为NULL,则使用默认属性。当锁的属性为默认时,可以通过宏PTHREAD_RWLOCK_INITIALIZER初始化,即 * pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER; 效果和调用当前方法并为attr传入NULL是一样的 * * @param rwlock 读写锁 * @param attr 读写锁的属性 * @return int 成功则返回0,否则返回错误码 */ int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);
pthread_rwlock_destroy()
#include <pthread.h> /** * @brief 销毁rwlock指向的读写锁对象,并释放它使用的所有资源。当任何线程持有锁的时候销毁锁,或尝试销毁一个未初始化的锁,结果是未定义的。 * * @param rwlock * @return int */ int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
pthread_rwlock_rdlock()
/** * @brief 应用一个读锁到rwlock指向的读写锁上,并使调用线程获得读锁。如果写线程持有锁,调用线程无法获得读锁,它会阻塞直至获得锁。 * * @param rwlock 读写锁 * @return int 成功返回0,失败返回错误码 */ int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
pthread_rwlock_wrlock()
/** * @brief 应用一个写锁到rwlock指向的读写锁上,并使调用线程获得写锁。只要任意线程持有读写锁,则调用线程无法获得写锁,它将阻塞直至获得写锁。 * * @param rwlock 读写锁 * @return int 成功返回0,失败返回错误码 */ int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
pthread_rwlock_unlock()
/** * @brief 释放调用线程锁持有的rwlock指向的读写锁。 * * @param rwlock 读写锁 * @return int 成功返回0.失败返回错误码 */ int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
案例:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
pthread_rwlock_t rwlock;
int shared_data = 0;
void* lock_reader(void* args) {
// 读写锁中的读是可以多个线程同时读取的
// 获取读锁
pthread_rwlock_rdlock(&rwlock);
printf("%s读取到:%d\n", (char*)args, shared_data);
pthread_rwlock_unlock(&rwlock);
free(args);
}
void* lock_writer(void* args) {
// 获取写锁
pthread_rwlock_wrlock(&rwlock);
sleep(1);
shared_data++;
printf("当前%s写入完毕,结果是%d\n", (char*)args, shared_data);
// 释放写锁
pthread_rwlock_unlock(&rwlock);
}
int main(int argc, char const* argv[])
{
// 动态初始化创建锁
pthread_rwlock_init(&rwlock,NULL);
pthread_t writer1, writer2;
pthread_t read[10];
// 写线程
pthread_create(&writer1, NULL, lock_writer, "写线程1");
pthread_create(&writer2, NULL, lock_writer, "写线程2");
sleep(3);
// 读线程
for (int i = 0;i < 10;i++) {
char* s = (char*)malloc(20 * sizeof(char));
sprintf(s, "读线程%d", i);
pthread_create(&read[i], NULL, lock_reader, s);
}
// 主线程等待
pthread_join(writer1, NULL);
pthread_join(writer2, NULL);
for (int i = 0;i < 10;i++) {
pthread_join(read[i], NULL);
}
pthread_rwlock_destroy(&rwlock);
return 0;
}
要注意的是,线程的执行顺序是由操作系统内核调度的,其运行规律并不简单地为“先创建先执行”。
写饥饿
多次运行后,我们发现,此时读操作总是连续执行的,且读操作休眠未结束时,写操作会被阻塞。与工作原理相符:① 读操作可以并发执行,相互之间不必争抢锁,多个读操作可以同时获得读锁;② 只要有一个线程持有读写锁,写操作就会被阻塞。我们在读操作中加了1s休眠,只要有一个读线程获得锁,在1s内写操作是无法执行的,其它读操作就可以有充足的时间执行,因此读操作就会连续发生,写操作必须等待所有读操作执行完毕方可获得读写锁执行写操作。这就是使用读写锁时存在的潜在问题:写饥饿。
解决方法
① 问题描述
读写锁的写饥饿问题(Writer Starvation)是指在使用读写锁时,写线程可能无限期地等待获取写锁,因为读线程持续地获取读锁而不断地推迟写线程的执行。这种情况通常在读操作远多于写操作时出现。
② 解决方案
Linux提供了可以修改的属性pthread_rwlockattr_t,默认情况下,属性中指定的策略为“读优先”,当写操作阻塞时,读线程依然可以获得读锁,从而在读操作并发较高时导致写饥饿问题。我们可以尝试将策略更改为“写优先”,当写操作阻塞时,读线程无法获取锁,避免了写线程持有锁的时间持续延长,使得写线程获取锁的等待时间显著降低,从而避免写饥饿问题。
pthread_rwlockattr_t
typedef union { char __size[__SIZEOF_PTHREAD_RWLOCKATTR_T]; long int __align; } pthread_rwlockattr_t;
pthread_rwlockattr_init
#include <pthread.h> /** * @brief 用所有属性的默认值初始化attr指向的属性对象 * * @param attr 读写锁属性对象指针 * @return int 成功返回0,失败返回错误码 */ int pthread_rwlockattr_init(pthread_rwlockattr_t *attr);
pthread_rwlockattr_destroy
#include <pthread.h> /** * @brief 销毁读写锁属性对象 * * @param attr 读写锁属性对象指针 * @return int 成功返回0,失败返回错误码 */ int pthread_rwlockattr_destroy(pthread_rwlockattr_t *attr);
pthread_rwlockattr_setkind_np
#include <pthread.h> /** * @brief 将attr指向的属性对象中的"锁类型"属性设置为pref规定的值 * * @param attr 读写锁属性对象指针 * @param pref 希望设置的锁类型,可以被设置为以下三种取值的其中一种 * PTHREAD_RWLOCK_PREFER_READER_NP: 默认值,读线程拥有更高优先级。当存在阻塞的写线程时,读线程仍然可以获得读写锁。只要不断有新的读线程,写线程将一直保持"饥饿"。 * PTHREAD_RWLOCK_PREFER_WRITER_NP: 写线程拥有更高优先级。这一选项被glibc忽略。 * PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP: 写线程拥有更高优先级,在当前系统环境下,它是有效的,将锁类型设置为该值以避免写饥饿。 * @return int 成功返回0,失败返回非零的错误码 */ int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t *attr, int pref);
案例:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
pthread_rwlock_t rwlock;
int shared_data = 0;
void* lock_reader(void* args) {
// 读写锁中的读是可以多个线程同时读取的
// 获取读锁
printf("%s开始获取读锁\n",(char*)args);
pthread_rwlock_rdlock(&rwlock);
printf("%s读取到:%d\n", (char*)args, shared_data);
sleep(1);
pthread_rwlock_unlock(&rwlock);
free(args);
}
void* lock_writer(void* args) {
// 获取写锁
printf("%s开始获取写锁\n",(char*)args);
pthread_rwlock_wrlock(&rwlock);
shared_data++;
printf("当前%s写入完毕,结果是%d\n", (char*)args, shared_data);
// 释放写锁
pthread_rwlock_unlock(&rwlock);
}
int main(int argc, char const* argv[])
{
// 创建写锁属性对象
pthread_rwlockattr_t attr;
pthread_rwlockattr_init(&attr);
// 设置写线程优先级
pthread_rwlockattr_setkind_np(&attr, PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP);
// 动态初始化创建锁
pthread_rwlock_init(&rwlock,&attr);
pthread_t writer1, writer2;
pthread_t read[10];
// 写线程
pthread_create(&writer1, NULL, lock_writer, "写线程1");
// 读线程
for (int i = 0;i < 10;i++) {
char* s = (char*)malloc(20 * sizeof(char));
sprintf(s, "读线程%d", i);
pthread_create(&read[i], NULL, lock_reader, s);
if (i == 4) {
pthread_create(&writer2, NULL, lock_writer, "写线程2");
}
}
// 主线程等待
pthread_join(writer1, NULL);
pthread_join(writer2, NULL);
for (int i = 0;i < 10;i++) {
pthread_join(read[i], NULL);
}
pthread_rwlock_destroy(&rwlock);
pthread_rwlockattr_destroy(&attr);
return 0;
}
可以看到,写线程是先于读线程的。 不会像前面那样,出现写饥饿问题。
自旋锁
(我们写的用户锁一般是要尽量避免空转的,而对于自旋锁是属于操作系统内核的锁,不需要像用户锁那样去避免空转)
在Linux内核中,自旋锁是一种用于多处理器系统中的低级同步机制,主要用于保护非常短的代码段或数据结构,以避免多个处理器同时访问共享资源。自旋锁相对于其他锁的优点是它们在锁被占用时会持续检查锁的状态(即“自旋”),而不是让线程进入休眠。这使得自旋锁在等待时间非常短的情况下非常有效,因为它避免了线程上下文切换的开销。
自旋锁主要用于内核模块或驱动程序中,避免上下文切换的开销。不能在用户空间使用。