【Linux】从 “抢资源” 到 “优雅控场”:Linux 互斥锁的原理与 C++ RAII 封装实战(Ⅰ)

一、互斥概念
共享资源:多个线程共有的资源。
临界资源:多线程执行流被保护的共享的资源就叫做临界资源。
临界区:每个线程内部,访问临界资源的代码,就叫做临界区互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
问题:一个进程内部的多个线程中,因为所有的线程共享地址空间,进程资源大部分都会被线程共享!如果多个线程同时访问共享资源呢?
答:首先多个线程访问的共享资源是栈资源,那么不会出现什么问题,因为各个线程都有各自的线程栈,但是如果这个资源不是栈资源,例如:显示屏,多个线程向显示屏打印数据,那么一定会导致打印的数据错乱,这个就是数据不一致问题,解决办法:线程同步与互斥。
数据不一致问题,本质就是多个执行流(线程或者进程)访问共享资源导致的,所以为了解决这个问题,我们就得想办法把共享资源变成临界资源,而把共享资源变成临界资源的本质就是保护临界区(保护访问共享资源的代码),怎么保护?使用互斥来保护。互斥就是多个执行流访问共享资源时,只让一个线程去访问,其他线程阻塞等待,当前面的线程访问结束,再让下一个线程来访问,这个过程就跟去银行排队取钱一样。那么要实现互斥的效果,就得使用到锁。

未对共享资源就行保护的情况:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <pthread.h> int ticket = 1000;//共享资源 void *route(void *arg) { char *id = (char *)arg; while (1) { if (ticket > 0)//临界区 { usleep(1000);//临界区 printf("%s sells ticket:%d\n", id, ticket);//临界区 ticket--;//临界区 }//临界区 else { break; } } return nullptr; } int main() { 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; }
加锁保护的情况:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <pthread.h> int ticket = 1000;//共享资源 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; } } return nullptr; } int main() { 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; }
问题:加锁的原则问题(就是在那个位置加锁,那个位置解锁)
答:首先如果没有锁,多个线程并发执行,但是加锁之后,只允许一个一个的线程执行,所以加锁必然会导致效率的降低,进而我们加锁的粒度(位置),必须要足够细!
问题:在上面的代码中,mutex 是一个共享资源,他保护别人,谁来保护他?
答:lock 和 unlock 被设计成原子的。
问题:有10个进程,6个进程加锁访问共享资源,其他4不加锁访问共享资源,这样行吗?
答:访问临界资源,所有的线程必须遵守加锁和解锁规则,不能有例外。
问题:申请锁失败(就是加锁失败)的线程在干什么?
答:10 个线程去访问临界资源,只有一把锁,10个线程去抢这一把锁,例如:1号线程拿到这把锁,他就能去访问临界资源,其余没抢到这把锁的线程,就在锁上阻塞等待,等到1号线程访问临界结束之后,解锁之后,其余的线程再抢这把锁。因为没有抢到这把的锁的线程只关心抢到这把锁的线程的加锁前、解锁后,不关心抢到这把锁的线程干了些什么,所以加锁是会保证原子性的。其实原子也体现到汇编语句上,占在 CPU 的视角,要么执行完了这条语句,要么没执行。
如果我们定义的锁不是全局的,必须使用:
#include <pthread.h> int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);mutex:指向待初始化的互斥锁对象指针
attr:互斥锁属性(如共享属性、类型等),传入 NULL 表示使用默认属性
当我们使用完这个锁之后,必须手动销毁:
#include <pthread.h> int pthread_mutex_destroy(pthread_mutex_t *mutex);参数 mutex:指向待销毁的互斥锁对象指针
返回值:成功返回 0,失败返回非 0 :错误码
注意:销毁前需确保互斥锁处于解锁状态,且无线程正在等待该锁,否则会导致未定义行为。
代码示例:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <iostream> #include <string> #include <pthread.h> int ticket = 1000;//共享资源 //pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//定义锁,他是个全局变量,当进程结束时,他就会被释放掉 class thread_data { public: thread_data(const std::string& n, pthread_mutex_t* p) : name(n), pmutex(p) { } public: std::string name; pthread_mutex_t* pmutex; }; void *route(void *arg) { thread_data* td = static_cast<thread_data*>(arg); while (1) { // pthread_mutex_lock(&mutex);//加锁 pthread_mutex_lock(td->pmutex); if (ticket > 0)//临界区 { usleep(1000);//临界区 printf("%s sells ticket:%d\n", td->name.c_str(), ticket);//临界区 ticket--;//临界区 // pthread_mutex_unlock(&mutex);//解锁 pthread_mutex_unlock(td->pmutex); }//临界区 else { // pthread_mutex_unlock(&mutex);//解锁 pthread_mutex_unlock(td->pmutex); break; } } return nullptr; } int main() { pthread_t t1, t2, t3, t4; pthread_mutex_t mutex;//局部锁 pthread_mutex_init(&mutex,nullptr);//初始化 thread_data m1("thread 1",&mutex),m2("thread 2",&mutex),m3("thread 3",&mutex),m4("thread 4",&mutex); pthread_create(&t1, NULL, route, (void *)&m1); pthread_create(&t2, NULL, route, (void *)&m2); pthread_create(&t3, NULL, route, (void *)&m3); pthread_create(&t4, NULL, route, (void *)&m4); pthread_join(t1, NULL); pthread_join(t2, NULL); pthread_join(t3, NULL); pthread_join(t4, NULL); pthread_mutex_destroy(&mutex);//销毁锁 return 0; } 
二、互斥锁的原理
锁的实现有两种方式:1、软件实现,2、硬件实现
为了更好的理解软件实现锁的原理,我们先聊一下硬件实现锁的原理:首先,多线程出现并发访问共享资源导致数据不一至的问题,本质是线程切换导致,那么如果我们不让线程切换,让当前线程跑完,就能实现锁,所以在硬件层面,我们只要屏蔽到时钟中断,让线程跑完,跑完之后再恢复时钟中断就行,这样就在硬件层面上实现了锁。
经过上面的例子,大家已经意识到单纯的 i++ 或者 ++i 都不是原子的(i++ 或者 ++i 的汇编语句至少三条,不能保证该操作是原子,进而有可能出现数据不一致问题),有可能会有数据一致性问题。
为了实现互斥锁操作,大多数体系结构都提供了 swap 或 exchange 指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。现在我们把 lock 和 unlock 的伪代码改一下。

先聊一下 lock :

我们定义的锁的本质就是一个变量,他里面存储就是1;我们一般的寄存器以32位系统为例,刚开始是例如英特尔一般都是16位的,后来要把寄存器扩展到32位,同时要兼容之前的16位,那么他把寄存器分成两部分:低 16 位叫 al ,高 16 位叫 aH。

当我们执行加锁的第一条汇编时:

就是把 0 放到这个 al 寄存器里面:

当我们执行加锁的第二条汇编时:

就把 al 这个寄存器的值和内存里 mutex 的值进行交换:

然后执行加锁的第三条汇编语句:

此时执行线程的CPU里面的 al 寄存器里面的值为 1 ,那么表示加锁成功,否则就挂起等待;直到有线程解锁,然后重复上面的工作。
补充几个知识再继续:
1)CPU 在调度线程时,是以线程为载体执行的加载逻辑
2)CPU 内的寄存器只有一套,但是寄存器里面的内的数据,可以有多份;也就是说:寄存器 != 寄存器里面的数据
3)内存中的变量,被线程共享,只要拿到地址。
结论:那么把内存中变量的值交换CPU的内部中,本质就是把这个共享数据变成某个线程的私有数据。
问题:在加锁的过程中会造成多线程的并发问题,进而导致数据不一致问题吗?
答:假设有两个线程:A,B ,无论哪个线程执行加锁的第一行汇编时,都不会有什么并发问题,所以我们主要讨论的是第二行汇编,当线程 A 执行加锁汇编,执行到:

时,此时:

如果此时线程A 被切走时,因为线程调度是要保留上下文数据的,而 al 寄存器里面的内容也属于线程 A 的数据所以,线程 A 就要带走他的上下文数据:

此时轮到线程 B 执行,那么线程 B 进行加锁汇编时:

因为内存里面的 mutex 的值以及被线程A 带走了,所以线程 B 执行这个汇编,执行个寂寞,例如:

上面这个图片是线程 B 执行加锁的第一行汇编,此时再执行第二行汇编:

交换寄存器 al 的内容和 mutex 的值,因为此时 al 寄存器里面的内容和 mutex 的值都为 0 ,所以执行这个汇编执行个寂寞:

此时线程B 开始执行第三行汇编:

所以线程 B 只能挂起等待。等到线程 A 解锁之后,线程 B 才能执行。
结论:锁就是内存中 mutex 里面的值:1,因为 exchange 方式,没有拷贝,所以至始至终只有一个 1 ,谁有这个 1 ,谁就拥有锁。所谓竞争锁就是竞争执行 exchange 。
接下来讨论一下解锁过程,一句话搞定:既然要解锁,那肯定是拥有锁的线程进行解锁操作:

解锁就把寄存器 al 里面的 1 ,还给内存中的 mutex :

问题:如果 CPUI 执行到某个线程的加锁之后,解锁之前的代码,此时这个线程会被切换吗?切换了会不会造成数据不一致问题?
答:可以被切换,不会造成数据不一致问题;拥有既然没有解锁,那么这个线程被切换,那么CPU 里面的寄存器 al 里面的值:1 ,就会被带走,所以只要这个线程不解锁,那么其他线程永远都不能访问临界资源。
互斥锁的本质就是 mutext 的值有 1 变成了 0 ,互斥的本质就是独占,独占的本质就是我们可以认为临界资源只有一份,谁拿到这个 1 谁就能拥有这份资源。我们可以把互斥锁理解成一个信号量,只不过这个信号量的值为1,表示一个资源。加锁的本质就是对资源的预订,不过该线程有没有访问这个临界资源,其他线程就是不能访问这份临界资源,直到该线程解锁。
C++ 方式使用锁:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <iostream> #include <string> #include <pthread.h> #include <mutex> int ticket = 1000;//共享资源 std::mutex lock;//C++方式使用锁 class thread_data { public: thread_data(const std::string& n, pthread_mutex_t* p) : name(n), pmutex(p) { } public: std::string name; pthread_mutex_t* pmutex; }; void *route(void *arg) { while (1) { lock.lock();//加锁 if (ticket > 0)//临界区 { usleep(1000);//临界区 printf("sells ticket:%d\n", ticket);//临界区 ticket--;//临界区 lock.unlock();//解锁 }//临界区 else { lock.unlock();//解锁 break; } } return nullptr; } int main() { pthread_create(&t1, NULL, route, (void *)nullptr); pthread_create(&t2, NULL, route, (void *)nullptr); pthread_create(&t3, NULL, route, (void *)nullptr); pthread_create(&t4, NULL, route, (void *)nullptr); pthread_join(t1, NULL); pthread_join(t2, NULL); pthread_join(t3, NULL); pthread_join(t4, NULL); return 0; }
注意:使用锁会导致代码运行效率的降低!慎用!!!
三、使用C++封装Linux的锁、RAII 风格的锁
#pragma once #include <iostream> #include <pthread.h> class Mutex { public: Mutex() { pthread_mutex_init(&_lock,nullptr); } void Lock()//加锁 { pthread_mutex_lock(&_lock); } void Unlock()//解锁 { pthread_mutex_unlock(&_lock); } ~Mutex() { pthread_mutex_destroy(&_lock);//销毁锁 } private: pthread_mutex_t _lock; }; class LockGuard//RAII风格 { public: LockGuard(Mutex& lock):_lockref(lock) { _lockref.Lock(); } ~LockGuard() { _lockref.Unlock(); } private: Mutex& _lockref; };#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <pthread.h> #include <sched.h> #include "Mutex.hpp" int ticket = 1000; Mutex lock;//定义锁 void *route(void *arg) { char *id = (char *)arg; while (1) { // lock.Lock();//加锁 //临界区 { LockGuard lockgurad(lock); // 一行搞定 if (ticket > 0) { usleep(1000); printf("%s sells ticket:%d\n", id, ticket); ticket--; lock.Unlock(); // 解锁 } else { lock.Unlock(); // 解锁 break; } } } return nullptr; } int main() { 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; }
未完待续!