上一篇:线程概念与控制https://blog.csdn.net/Small_entreprene/article/details/146704881?sharetype=blogdetail&sharerId=146704881&sharerefer=PC&sharesource=Small_entreprene&sharefrom=mp_from_link我们学习了线程的控制及其相关概念之后,我们清楚:线程是共享地址空间的,所以线程会共享大部分资源。对于多线程来说,访问的共享资源称为共功资源,而多执行流访问公共资源的时候,可能会造成多种情况的数据不一致问题,因为公共资源并没有加保护,为了解决这样的问题,我们就下来就要学习同步与互斥:
互斥话题
在当前学习进程间通信中的信号量的时候,我们有谈及,现在我们来快速看看什么是互斥,互斥的相关概念:
进程与线程(执行流)互斥机制的基本概念:
-
临界资源:指在多线程执行过程中,被多个线程共享的资源。(可以理解为被保护起来的共享资源)
-
临界区:指线程内部用于访问临界资源的代码段。
-
互斥:确保在任何给定时刻,只有一个执行线程能够进入临界区,从而对临界资源进行访问,这通常用于保护临界资源。
-
原子性:指一个操作在执行过程中不会被任何调度机制中断,该操作要么完全执行,要么完全不执行。(后续将讨论如何实现原子性)
看一个现象
我们下面来见见一种现象(除了多执行流往显示器文件上打印这个抢占临界资源的现象外,另外一种数据不一致问题),然后快速的使用锁(pthread锁/互斥锁)来进行解决一下:
样例代码:简单的模拟一下抢票过程(多线程进行抢票)
代码是一个简单的多线程售票系统的示例,其中包含一个共享变量 ticket
,表示剩余的票数。代码中创建了四个线程,每个线程都试图通过调用 route
函数来销售车票。然而,由于多个线程同时访问和修改 ticket
变量,这会导致竞态条件(race condition),从而使得程序的行为不可预测。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>int ticket = 100; // 初始化票数为100void *route(void *arg)
{char *id = (char*)arg; // 从线程参数中获取线程IDwhile ( 1 ) // 无限循环,直到票卖完{if ( ticket > 0 ) // 如果还有票{usleep(1000); // 模拟售票操作的延时printf("%s sells ticket:%d\n", id, ticket); // 打印售票信息ticket--; // 票数减一} else {break; // 如果票卖完了,退出循环}}return nullptr; // 线程结束
}int main( void )
{pthread_t t1, t2, t3, t4; // 定义四个线程的变量pthread_create(&t1, NULL, route, (void*)"thread 1"); // 创建线程1pthread_create(&t2, NULL, route, (void*)"thread 2"); // 创建线程2pthread_create(&t3, NULL, route, (void*)"thread 3"); // 创建线程3pthread_create(&t4, NULL, route, (void*)"thread 4"); // 创建线程4pthread_join(t1, NULL); // 等待线程1结束pthread_join(t2, NULL); // 等待线程2结束pthread_join(t3, NULL); // 等待线程3结束pthread_join(t4, NULL); // 等待线程4结束return 0; // 程序结束
}
票数竟然是负数!!!
解决这个问题
由于 ticket
是一个共享变量,且在 routine
函数中没有适当的同步机制来保护对它的访问,因此当多个线程同时执行 ticket--
操作时,可能会出现以下问题:
-
票数不准确:可能会售出超过100张的票,因为多个线程可能同时读取相同的
ticket
值,然后各自减一。 -
数据竞争:多个线程同时写入
ticket
变量,导致最终的票数不正确。
为了解决这个现象,可以使用互斥锁(mutex)来同步对 ticket
变量的访问。以下是修改后的代码示例:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>int ticket = 100; // 初始化票数为100
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; // 初始化互斥锁void *route(void *arg)
{char *id = (char *)arg; // 从线程参数中获取线程IDwhile (1) // 无限循环,直到票卖完{pthread_mutex_lock(&lock); // 加锁if (ticket > 0) // 如果还有票{usleep(1000); // 模拟售票操作的延时printf("%s sells ticket:%d\n", id, ticket); // 打印售票信息ticket--; // 票数减一}pthread_mutex_unlock(&lock); // 解锁if (ticket <= 0){break; // 如果票卖完了,退出循环}}return NULL; // 线程结束
}int main(void)
{pthread_t t1, t2, t3, t4; // 定义四个线程的变量pthread_create(&t1, NULL, route, (void *)"thread 1"); // 创建线程1pthread_create(&t2, NULL, route, (void *)"thread 2"); // 创建线程2pthread_create(&t3, NULL, route, (void *)"thread 3"); // 创建线程3pthread_create(&t4, NULL, route, (void *)"thread 4"); // 创建线程4pthread_join(t1, NULL); // 等待线程1结束pthread_join(t2, NULL); // 等待线程2结束pthread_join(t3, NULL); // 等待线程3结束pthread_join(t4, NULL); // 等待线程4结束pthread_mutex_destroy(&lock); // 销毁互斥锁return 0; // 程序结束
}
理解为什么会数据不一致&&认识加锁的接口
我们先来理解一下这数据为什么会不一致!?
为什么票数会减到负数?
ticket --和 if ( ticket > 0)判断是导致数据不一致的主要影响。(--不是主要矛盾,但是和它确实有关)
由于 ticket--
不是原子的(要么减,要么不减),ticket这个变量是在内存当中的,在计算机当中,对一个变量作 -- 其实本身属于算数运算,而在计算机冯诺依曼体系当中,目前我们知道,我们对变量作 -- 操作只能由CPU来进行计算,也就是说在我们所对应的整个计算机当中,只有CPU能够对这个ticket作 -- ,这时候:(简单理解,最主要是三步)
- 第一步:需要将ticket读入到CPU当中(从内存到CPU,严格来说是导入到CPU的某些寄存器,比如说ebx)(内存本身没有计算能力)。
- 第二步:CPU要进行对该寄存器里的值作减操作。
- 第三步:由于 -- 操作是会影响原始的值的,并不是作减法操作,所以需要将减完之后的值写回内存,写回内存要来保证对原始值作更改(100->99)。
其实CPU对ticket的一系列操作,宏观上就是cpu正在执行一个进程或线程的代码,在做进程,线程调度(ticket--),所以从执行流的调度上来讲,CPU会存在当前执行流的上下文数据(防止临时变量因为执行流的切换造成数据的丢失)—————上面作为背景知识,下面来解释说为什么ticket--是原子性的:
CPU内,除了数据类的寄存器,还有pc指针,程序计数器等等,假设pc指针当前指向的是0xff00,代表正在执行ticket--,我们上面的三步就可以翻译成汇编指令:(大概)
0XFF00 mov ebx ticket
0XFF02 减少 ebx 1
0XFF04 mov 写回 ticket所对应的地址 ebx
如图:
在正准备执行第三条语句的时候,该进程发生了切换,线程切换,就需要保存上下文数据,保存了 ebp: 99 ,pc指针: 0XFF04 (正准备执行第三条语句) 在执行第三条语句的时候,线程被切换了,该上下文就被放入到系统的等待队列了:
此时CPU内的所有寄存器的数据已经变相的废弃了,因为可以被覆盖了,CPU就继续选择一个线程B进行调度(上面链入等待队列的我们称为线程A),线程B也有自己的代码和数据,但是连两个线程是执行的相同代码,所以执行地址没有发生改变,将此时CPU寄存器内的值进行覆盖(不需要害怕覆盖造成数据的丢失,因为线程A上下文数据是被保存起来了),线程B照常执行,没有其他线程影响,执行完后,就将100减减之后的99,写回内存了:
线程B就完整的完成了一次ticket--,假设线程B运气很好,一次就将ticket的值减到1,准备将ticket的值减为0时,此时pc指针指向0XFF00,线程B正准备执行0XFF00的时候,线程B被切换了,吸纳线程B就要保存自己的上下文数据:
线程A调回来了,但是CPU首先不是调度线程A,而是对线程A进行恢复上下文:
回复完后执行的是第三步,这就将寄存器中的99写回到内存,这就导致线程B之前的工作全部白做了!!!这就造成了数据不一致问题!我们这个例子为的是解释ticket--操作不是原子的。
我想说的是:一个整数的全局变量,并不是原子的!!! (因为C语言的--操作是会被转化成3条汇编,三条汇编之间的任意一个位置,线程都可能被中断切换,然后换入下一个线程;又因为线程资源是共享的,所以对应的ticket--操作并不是--的)(这也是为什么我们之前说信号量本质就是一个计数器,但是我们不敢用一个整数的全局变量来进行++/--,因为其操作不是原子的)
所以,简单来说:当前,一种对原子性极简式的理解是,当前来看,一条汇编语句,就是原子的!!!
但是,为什么票数被减到了负数?
其实是该语句:if(ticket > 0) 是主要矛盾!!!对ticket是否大于0进行判断,其实本质是一种计算,这种计算,我们称之为逻辑计算,我们得到的是bool值,而且所有线程都是需要对其进行判断的,假设ticket被安全的减到1,此时线程1将ticket载入到寄存器当中,准备要进行逻辑运算,我们可以将其操作看成两步:
- 载入
- 判断
但是在执行载入之后,还没有执行下一条汇编语句的时候,线程1被线程2切换走了,线程2将ticket载入了,也是和线程1遭遇一样,被切走了,依次,切到线程4,也是在第一步后被切走,之后,线程1,2,3,4就按照顺序唤醒:
- 线程1执行--,就将其:1--->0;
- 线程2执行--,就将其:0--->-1;
- 线程3执行--,就将其:-1--->-2;
- 线程4执行--,就将其:-2--->-3;
判断也是访问共享资源!!!其中usleep也是为了堆积线程,然后才能使数据不一致现象更具直观性!
综上:一个全局资源没有加保护,可能在多线程执行的时候,发生并发问题。我们将多线程导致的并发问题,比如说上面的抢票问题,我们称之为线程安全问题!!!
该routine函数也是被多执行流进入了,因为函数内部又全局资源,所以该函数是不可重入函数。
其实为了让我们该抢票抢到负数,usleep后续辅助之外,重要的是,在多线程中,要制造更多的并发,更多的切换,我们并发的话,是创建了4个线程,那么,我们来好好谈谈这“更多的切换”
我们知道,线程切换其实就是对当前线程的上下文数据,线程上下文切换通常由以下几种情况触发:
-
时间片到期:操作系统为每个线程分配一个时间片(Time Quantum),当线程运行的时间达到分配的时间片时,操作系统会强制切换到其他线程。
-
线程阻塞:线程在等待某些资源(如 I/O 操作、锁等)时会进入阻塞状态,操作系统会切换到其他就绪的线程。
-
线程优先级调整:操作系统根据线程的优先级动态调整线程的调度顺序,高优先级的线程可能会中断低优先级的线程。
-
线程主动让出 CPU:线程可以通过调用某些系统调用(如
yield
)主动让出 CPU,操作系统会切换到其他线程。
当线程调用 usleep
函数时,它会通过系统调用陷入内核态。内核记录线程的暂停时间,并将其置为等待状态。时间到期后,线程被唤醒并准备返回用户态。此时,内核会恢复线程的上下文信息,检查线程状态和资源使用情况,确保一切正常后,通过特定指令将控制权安全地交还给用户态线程,使其从暂停处继续执行。
所以解决数据不一致问题,我们就需要引入锁得概念:
pthread库为我们用户提供线程得相关接口,线程在并发访问的时候,全局资源那么多,线程就注定要为我们用户提供各种各样得锁,我们所要使用到得锁:pthread_mutex_t:
pthread_mutex_t
是 POSIX 线程库(pthread)中用于实现互斥锁(Mutex)的数据类型,主要用于在多线程程序中保护共享资源,防止多个线程同时访问导致数据竞争和不一致问题。
互斥锁是一种同步机制,用于确保一次只有一个线程可以访问共享资源。当一个线程获取了互斥锁后,其他线程必须等待,直到该线程释放锁。
pthread_mutex_t
是一个不透明的数据类型,其具体实现由线程库提供。通常,它是通过结构体或其他数据结构来实现的,但用户不需要直接操作其内部细节。
互斥锁可以通过静态初始化或动态初始化来创建。
静态初始化
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
这种方式适用于全局或静态变量的互斥锁。一旦是这么定义得,那么该锁不需要被释放!!!该锁会在程序执行结束之后,自动释放
动态初始化
pthread_mutex_t lock;
pthread_mutex_init(&lock, NULL);
这种方式适用于动态分配的互斥锁。pthread_mutex_init
函数的第二个参数是一个指向 pthread_mutexattr_t
的指针,用于设置互斥锁的属性。如果传入 NULL
,则使用默认属性。之后不使用了,需要对对应的锁进行释放!!!
其实归根结底,所有得锁都要被所有进程看到的,不管是以参数的形式,假如在main函数中定义,然后传给所有线程,还是说直接定义全局得锁,因为锁要保护我们的代码,所有线程在访问临界资源之前,必须先申请锁,申请锁就需要先看到锁。
怎么申请锁:pthread_mutex_lock(&lock);
加锁(属于申请锁的阻塞版本)
pthread_mutex_lock(&lock);
所有线程竞争锁,如果锁已经被其他线程占用,调用线程将阻塞挂起,直到锁被释放。
不过,多线程竞争申请锁,多线程都得先看到锁,所本身就是临界资源,锁是来保护共享资源的,那么谁来保护锁的安全呢?
所以,pthread_mutex_lock(&lock);这个动作要求是要具有原子性的!!!
加锁成功,线程就继续向后运行,访问临界区代码,访问临界资源;加锁失败,线程就会阻塞挂起,所以锁提供的能力的本质:执行临界区代码由并行转化成串行。
注意:加锁:尽量加锁的范围粒度要比较细,尽可能的不要包含太多的非临界区代码。
尝试加锁(属于申请锁的非阻塞版本)
int ret = pthread_mutex_trylock(&lock);
if (ret == 0) {// 锁获取成功
} else if (ret == EBUSY) {// 锁已被占用,获取失败
}
pthread_mutex_trylock
会尝试获取锁,但如果锁已经被占用,它不会阻塞,而是立即返回 EBUSY
。
解锁
pthread_mutex_unlock(&lock);
释放锁,允许其他线程获取锁。
当互斥锁不再使用时,可以通过以下函数销毁:
pthread_mutex_destroy(&lock);
销毁互斥锁后,其占用的资源将被释放,但互斥锁不能再被使用。
所以,我们对于来简单的来丰富一下解决方案:(此时还没有进行加锁和解锁)
int ticket = 1000;class ThreadData
{
public:ThreadData(const std::string &n, pthread_mutex_t &lock): name(n),lockp(&lock){}~ThreadData() {}std::string name;pthread_mutex_t *lockp;
};// 加锁:尽量加锁的范围粒度要比较细,尽可能的不要包含太多的非临界区代码
void *route(void *arg)
{ThreadData *td = static_cast<ThreadData *>(arg);while (1){if (ticket > 0){usleep(1000);printf("%s sells ticket:%d\n", td->name.c_str(), ticket);ticket--;}else{break;}}return nullptr;
}int main(void)
{pthread_mutex_t lock;pthread_mutex_init(&lock, nullptr); // 初始化锁pthread_t t1, t2, t3, t4;ThreadData *td1 = new ThreadData("thread 1", lock);pthread_create(&t1, NULL, route, td1);ThreadData *td2 = new ThreadData("thread 2", lock);pthread_create(&t2, NULL, route, td2);ThreadData *td3 = new ThreadData("thread 3", lock);pthread_create(&t3, NULL, route, td3);ThreadData *td4 = new ThreadData("thread 4", lock);pthread_create(&t4, NULL, route, td4);pthread_join(t1, NULL);pthread_join(t2, NULL);pthread_join(t3, NULL);pthread_join(t4, NULL);pthread_mutex_destroy(&lock);//锁用完了,就要释放锁!!!:我们是动态初始化的锁(局部的锁)return 0;
}
因为加锁是要尽量细化加锁的范围粒度,尽可能的不要包含太多的非临界区代码,所以,因为在routine函数中,while语句当中的if语句都是临界资源,所以,我们需要在if前进行加锁。不过,不能在while之前进行加锁,不然就会导致一个线程独自将ticket--为0了:
//routine函数中进行加锁(对共享资源进行保护: 共享资源--->临界资源)while (1){pthread_mutex_lock(td->lockp);//进行加锁if (ticket > 0)//.....}
加锁完成之后,我们需要进行解锁,以实现其他线程获取锁。但是如下的解锁位置是错误的:
// 加锁:尽量加锁的范围粒度要比较细,尽可能的不要包含太多的非临界区代码
void *route(void *arg)
{ThreadData *td = static_cast<ThreadData *>(arg);while (1){// LockGuard guard(*td->lockp); // 加锁完成, RAII风格的互斥锁的实现pthread_mutex_lock(td->lockp);//进行加锁if (ticket > 0){usleep(1000);printf("%s sells ticket:%d\n", td->name.c_str(), ticket);ticket--;}//错误的解锁位置pthread_mutex_unlock(td->lockp);else{break;}}return nullptr;
}
逻辑错误:解锁位置可能导致死锁
在代码中,pthread_mutex_unlock(td->lockp);
被放置在了 if (ticket > 0)
的代码块之外,但与 else
语句同级。这会导致以下问题:
-
如果
ticket > 0
,线程会执行pthread_mutex_unlock(td->lockp);
,这是正常的解锁操作。 -
但如果
ticket <= 0
,线程会进入else
分支并执行break
,此时 线程会直接退出循环,而没有执行解锁操作。 -
结果:如果线程在
ticket <= 0
时退出循环,它会持有锁但没有释放锁,导致其他线程无法获取锁,从而引发死锁。
代码风格问题:解锁位置不清晰
-
解锁操作应该与加锁操作对称,即在加锁的代码块结束时进行解锁。在当前代码中,解锁操作被放置在了错误的位置,导致代码逻辑不清晰,容易引发错误。
-
正确的做法是将解锁操作放在加锁代码块的末尾,确保无论是否进入
if
或else
分支,锁都能被正确释放。(当然也可以在if和else分支里都进行解锁,但是这样的话就会有点代码冗余)
下面是修正后的代码:
void *route(void *arg)
{ThreadData *td = static_cast<ThreadData *>(arg);while (1){pthread_mutex_lock(td->lockp); // 加锁if (ticket > 0){usleep(1000);printf("%s sells ticket:%d\n", td->name.c_str(), ticket);ticket--;}else{break;}pthread_mutex_unlock(td->lockp); // 正确的解锁位置}return nullptr;
}
我们运行这个代码:
所以,至此我们就完成了对共享资源的保护了!!! (上面我们使用的是局部的锁,也就是动态初始化的锁,我们也可以使用全局的锁,对应的就对全局的锁进行解锁了,而且不需要对其全局的锁进行销毁)
互斥锁的属性可以通过 pthread_mutexattr_t
来设置。例如:
-
互斥锁类型:可以设置为普通锁(
PTHREAD_MUTEX_NORMAL
)、递归锁(PTHREAD_MUTEX_RECURSIVE
)、错误检查锁(PTHREAD_MUTEX_ERRORCHECK
)等。 - 共享属性:可以设置为进程内共享(
PTHREAD_PROCESS_PRIVATE
)或进程间共享(PTHREAD_PROCESS_SHARED
)。
- 死锁:如果线程获取锁后没有释放,或者多个线程以不同的顺序获取多个锁,可能会导致死锁。需要谨慎设计锁的使用逻辑。错误的解锁位置可能导致线程在某些情况下没有释放锁,从而引发死锁。
-
递归锁:如果需要在同一个线程中多次获取同一个锁,可以使用递归锁(
PTHREAD_MUTEX_RECURSIVE
)。 -
线程安全:互斥锁是线程安全的,但需要正确使用,否则可能导致数据竞争或死锁。
理解锁
锁被看到,意味着锁也是全局的,但是进程之间想要看到的话就很难,不过线程间想要看到是很容易的,因为线程资源是共享的。但是进程间互斥之前没有讲过,之说了线程间互斥,那么两个进程之间该如何实现互斥呢?
假设有两个进程,创建出来一个共享内存,锁(pthread_mutex_t)的本质就是一个变量,一个空间,我们直接将共享内存的其实地址shm,进行强制类型转化,即 (pthread_mutex_t*)shm; 我们不就可以直接使用pthread_mutex_init(); 进行初始化,使用 pthread_mutex_destroy(); 进行销毁,剩下的就可以通过锁的机制,实现进程间互斥了。
不过我们会避免多进程进行通信(以共享内存的方式,锁只是实现进程间互斥的解决办法),后面学习网络之后,自然知道是为什么了。
加锁之后,在临界区内部,允许线程切换吗?切换了会怎么样?
不管是临界区还是非临界区,在操作系统看来,终归还是代码,所以都是允许随时切换的,那么我们在代码中ticket--,不会还会出现我们上面讨论的问题了吗?
举个例子:
超级自习室:是一个只允许一个人的自习室。
超级自习室旁边的墙上:只有一把钥匙。
我:线程。
每次超级自习室都要好多人惦记,不过,今天我来得比较早,所以我将墙上的钥匙(获取锁成功)拿到了,我就进入到超级自习室里做我在超级自习室里面该做的事情(执行临界区代码),但是超级自习室只容许一个人,所以,我就将其锁住了,其他人并进不来,因为他们没有钥匙,我在里里面呆了一段时间,想去上厕所,因此我出来将其门锁上(线程切换:所以线程是可以切换的),因为其他人并没有我这一把唯一可以进入到超级自习室的锁,所以其他人是进不来的。
经过上面的简单的生活例子,我们知道,其实线程切换并不影响,并不会引发上面没有加锁的数据不一致问题,因为当前线程并没有释放锁,当前线程是持有锁进行切换的,即便被切换走了,其他线程也需要等待该线程回来执行完代码,然后释放锁,其他线程才能够展开对锁的竞争,进入临界区!!!
所以站在外面的人是怎么看待我的自习过程呢?
站在外面人的角度:要么不用这个超级自习室,要么用完这个超级自习室,对外面的人才有意义。 (这就是原子性的特性,我们可以理解为:我的自习,对外面的人,是原子的!!!)(我们上面是将原子性简单理解为一条汇编语句,知道了锁后,我们可以理解说,我们可以将一个临界区进行原子化!!!)
有了上面的过度,我们接下来,来真正理解一下,什么才是锁。
锁的原理
锁的原理是通过一系列机制来确保对共享资源的访问是安全的,避免多个线程或进程同时修改共享资源导致的数据不一致问题。锁的核心目标是互斥(Mutual Exclusion)和同步(Synchronization)。
锁不一定是互斥锁。锁是一个更广泛的概念,互斥锁(Mutex)只是其中一种常见的类型。我们下面对于互斥目标,主要围绕互斥锁来进行理解。
硬件级实现(只有内核在用):关闭时钟中断
对于一个代码块(不是一条汇编),这代码块可以随时被切换,切换是因为时间片到了,操作系统会一直调度线程,一直在做中断,一直检测线程的时间片,一旦切换,代码就发生交叉了,所以锁的实现的硬件级有一个最简单粗暴的做法:关闭时钟中断。
关闭时钟中断的原理
关闭时钟中断的基本思想是:
-
关闭中断:在进入临界区之前,关闭时钟中断,这样当前线程不会被抢占,从而确保临界区代码不会被其他线程中断。
-
执行临界区代码:在没有中断的情况下,安全地执行临界区代码。
-
打开中断:临界区代码执行完毕后,重新打开中断,恢复正常的线程调度。
这种方法的优点是简单直接,但缺点也非常明显:
-
风险高:如果临界区代码执行时间过长,或者发生死循环,会导致系统无法响应中断,从而导致系统死机。
-
仅适用于单核处理器:在多核处理器中,关闭中断无法阻止其他核心上的线程访问共享资源。
现代操作系统中的锁实现
现代操作系统和多核处理器环境中,锁的实现主要依赖于硬件级的原子操作(如 compare-and-swap
和 test-and-set
),而不是关闭中断。这些原子操作由处理器直接支持,确保在多核环境中,对共享变量的操作是原子的。
软件实现(大多使用的锁,并不简单粗暴,使用交换)
为了实现互斥锁操作,大多数体系结构都提供了 swap
或 exchange
指令。该指令的作用是把寄存器和内存单元的数据相交换。由于只有一条指令,保证了原子性。即使是多处理器平台,访问内存的总线周期也有先后。一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期。
下面就是pthread_mutex_lock和pthread_mutex_unlock的伪代码:
其实锁就是一种标记位,在内存当中也是需要开辟空间的,我们可以将其锁暂时看成是一个整数:为1,表示该锁是没有任何线程持有的:
接下来,肯定会有很多线程来申请锁,我们就假设为两个线程:线程A和线程B。
我们发现,申请锁的操作都和%al这个寄存器有关(将其置0,在放入当前mutex的值)。进程或者线程切换时,CPU内的寄存器硬件只有一套,但CPU寄存器内的数据可以有多份,各自的一份称为当前执行流的上下文。(切换时,自己打包带走)
换句话说,如果把一个变量的内容,交换到CPU寄存器内部,本质就是把该变量的内容获取到当前执行流的硬件上下文中,再本质一点就是当前CPU寄存器硬件上下文(其实就是各个寄存器的内容)属于进程/线程私有的!!!
所以我们使用swap/exchange将内存中的变量交换到CPU的寄存器中,本质时当前线程/进程在获取锁,因为是交换,不是拷贝!!!所以mutex当中的1,只有一份!!!所以谁申请,谁就持有锁!!!
线程A和线程B申请锁,进来 movb $0, %al ,可能A进来到这就被切走了,但是没有关系,因为该步骤是清理自己的数据的,彼此不会互相影响。
A执行到xchgb %al, mutex 后,可能被切换,线程A被切换走,会带走:%a: 1 ,当线程B要交换时,就是拿mutex中的0换0,因为线程A将1带走了(这就是交换的效果,不是拷贝)。这就是申请锁!!!
unlock就很简单了,只需要将mutex的内容置1(movb $1, mutex),这就可以保证mutex有1可以被其他线程交换拿到了。这就是解锁!!!
我们ticket--不是原子性的就是因为是拷贝,不是交换!!!
上面就是互斥锁,mutex的原理!!!
C++11其实也是为我们用户提供了: std::mutex(头文件:#include<mutex>)就是封装了锁。
那么我们现在来自己封装一个互斥量,面向对象化:
互斥量的封装
Mutex.hpp
#pragma once
#include <iostream>
#include <pthread.h>namespace MutexModule
{// 自定义互斥锁类,封装了 pthread_mutex_t 的操作class Mutex{public:// 构造函数:初始化互斥锁Mutex(){// 使用 pthread_mutex_init 初始化互斥锁// nullptr 表示使用默认的互斥锁属性pthread_mutex_init(&_mutex, nullptr);}// 加锁操作void Lock(){// 调用 pthread_mutex_lock 尝试加锁// 如果锁已被其他线程持有,当前线程将阻塞int n = pthread_mutex_lock(&_mutex);(void)n; // 忽略返回值,实际使用中应检查返回值处理错误}// 解锁操作void Unlock(){// 调用 pthread_mutex_unlock 释放锁// 只有持有锁的线程可以解锁int n = pthread_mutex_unlock(&_mutex);(void)n; // 忽略返回值,实际使用中应检查返回值处理错误}// 析构函数:销毁互斥锁~Mutex(){// 在对象销毁时,释放互斥锁资源pthread_mutex_destroy(&_mutex);}private:pthread_mutex_t _mutex; // 内部使用的 pthread 互斥锁};// RAII 风格的锁管理类,确保互斥锁在作用域结束时自动释放class LockGuard{public:// 构造函数:自动加锁LockGuard(Mutex &mutex) : _mutex(mutex){// 在构造时调用 Mutex 的 Lock 方法加锁_mutex.Lock();}// 析构函数:自动解锁~LockGuard(){// 在作用域结束时,自动调用 Mutex 的 Unlock 方法释放锁_mutex.Unlock();}private:Mutex &_mutex; // 引用一个 Mutex 对象};
}
其中:
// RAII 风格的锁管理类,确保互斥锁在作用域结束时自动释放class LockGuard{public:// 构造函数:自动加锁LockGuard(Mutex &mutex) : _mutex(mutex){// 在构造时调用 Mutex 的 Lock 方法加锁_mutex.Lock();}// 析构函数:自动解锁~LockGuard(){// 在作用域结束时,自动调用 Mutex 的 Unlock 方法释放锁_mutex.Unlock();}private:Mutex &_mutex; // 引用一个 Mutex 对象};
LockGuard
类通过 RAII(资源获取即初始化)风格管理锁,确保互斥锁在作用域开始时自动加锁,并在作用域结束时自动解锁,从而有效避免因忘记解锁导致的死锁问题,简化代码逻辑,提高线程安全性和程序的可靠性。 (智能指针原理也是类似的)
testMutex.cpp
#include <iostream>
#include <mutex>
#include <string>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include "Mutex.hpp"using namespace MutexModule;int ticket = 1000;class ThreadData
{
public:ThreadData(const std::string &n, Mutex &lock): name(n),lockp(&lock){}~ThreadData() {}std::string name;Mutex *lockp;
};// 加锁:尽量加锁的范围粒度要比较细,尽可能的不要包含太多的非临界区代码
void *route(void *arg)
{ThreadData *td = static_cast<ThreadData *>(arg);while (1){LockGuard guard(*td->lockp); // 加锁完成, RAII风格的互斥锁的实现if (ticket > 0){usleep(1000);printf("%s sells ticket:%d\n", td->name.c_str(), ticket);ticket--;}else{break;}usleep(123);}return nullptr;
}int main(void)
{Mutex lock;pthread_t t1, t2, t3, t4;ThreadData *td1 = new ThreadData("thread 1", lock);pthread_create(&t1, NULL, route, td1);ThreadData *td2 = new ThreadData("thread 2", lock);pthread_create(&t2, NULL, route, td2);ThreadData *td3 = new ThreadData("thread 3", lock);pthread_create(&t3, NULL, route, td3);ThreadData *td4 = new ThreadData("thread 4", lock);pthread_create(&t4, NULL, route, td4);pthread_join(t1, NULL);pthread_join(t2, NULL);pthread_join(t3, NULL);pthread_join(t4, NULL);return 0;
}