在前面的文章中我们已经介绍了有关互斥锁的概念与使用,本篇将开始介绍在 Linux 中的自旋锁和读写锁。这三种锁分别用于在不同的应用场景之中,其中互斥锁最为常用,但是我们需要了解一下其他的锁。
对于自旋锁和读写锁都介绍了其原理以及接口使用,并且给出了样例代码。
目录
自旋锁
1. 自选锁概念和原理
2. 自旋锁的优缺点与接口使用
3. 自旋锁使用样例代码
读写锁
1. 读者写者模型 vs 生产消费模型
2. 读写锁伪代码和读写锁函数接口
3. 读者优先和写者有限
4. 读写锁使用样例代码
自旋锁
在使用互斥锁对我们的临界资源上锁时,当其他线程抢不到锁,则会在系统调用中阻塞起来(操作系统将线程的 tcb 放入到等待队列中),等到抢到锁之后又会将线程唤醒(将线程的 tcb 从等待队列中放入到运行队列中)。但是假若在临界区运行的时间还没有将线程挂起等待的时间长,那么还不如让线程不断的轮询访问我们的锁,不将线程挂起等待,当锁一但释放就可以立即的获取到锁。
自旋锁的应用中应用层中使用的非常少,但是在操作系统层面使用得很多,因为操作系统不会轻易的将自己挂起等待,所以对于某些资源需要等到访问的时候,通常都是轮询等待。
1. 自选锁概念和原理
自旋锁概念:一种多线程同步机制,用于保护共享资源免受并发访问的影响。在多个线程尝试获取到锁时,它们会持续旋转(在循环中不断的检查锁是否可用)而不是立即进入休眠状态等待锁的释放。这种机制减少了线程切换的开销(因为假若是阻塞等待则会将线程挂起然后切换,而轮询则是在时间片内一直访问),适用于短时间内锁竞争的情况。但是假若不合理的使用(在临界区的运行时间较长),则会导致 CPU 的浪费。
原理:自旋锁通常使用一个共享的标志位来表示锁的状态。当标志位为 true 时,表示锁已被某个线程占用;当标志位为 false 的时候,表示锁可用。当一个线程尝试获取自旋锁的时候,会不断的检查标志位。:
1. 若标志位为 false,表示锁可以使用,线程将标志位设置为 true,表示占用了锁,然后进入到临界区中执行代码;
2. 若标志位为 true,表示锁已经被其他先占用,线程会在一个循环中不断自旋等待,直到锁被释放。
对于自旋锁代码实现的伪代码如下:
typedef _Atomic struct { #if __GCC_ATOMIC_TEST_AND_SET_TRUEVAL == 1 _Bool __val; #else unsigned char __val; #endif } atomic_flag; // ATOMIC_FLAG_INIT 值为0 atomic_flag spinlock = ATOMIC_FLAG_INIT; // 尝试获取锁 void spinlock_lock() { // 没有获取到锁则一直运行,返回值为true // 获取到锁,返回值为false,跳出循环等待 while (atomic_flag_test_and_set(&spinlock)) { // 如果锁被占用,则忙等待 } } // 释放锁 void spinlock_unlock() { atomic_flag_clear(&spinlock); } atomic_flag_test_and_set 函数检查 atomic_flag 的当前状态。如果 atomic_flag 之前没有被设置过(即其值为 false 或“未设置”状态),则函数会将其 设置为 true(或“设置”状态),并返回先前的值(在这种情况下为 false)。如果 atomic_flag 之前已经被设置过(即其值为 true),则函数不会改变其状态,但会 返回 true。
对于以上伪代码的实现,特别是对 atomic_flag 的操作一定得是原子的,这样才能保证 atomic_flag 的读取和修改在多线程环境中是不可分割的,同时这样保证了线程安全的问题。
2. 自旋锁的优缺点与接口使用
优点:
1. 低延迟:自旋锁适用于短时间内的锁竞争情况,因为他不会让线程进入休眠状态,从而避免了线程切换的开销,提高了锁操作的效率。
2. 减少系统调度开销:等待锁的线程不会被阻塞,不需要切换上下文,减少了系统调度的开销。
缺点:
1. CPU 资源的浪费:若锁持有的时间较长,等到获取线程的线程会一直循环等待,导致 CPU 资源的浪费。
2. 可能引起活锁:当多个线程同时自旋等待同一个锁的时候,若没有适当的退避政策,可能会导致所有线程都在不断检查锁的状态,从而无法进入临界区,形成活锁。
所以对于自旋锁,常用于短暂等待的情况和多线程锁的使用(通常用于底层,同步多个 CPU 对共享资源的访问)。
自旋锁常用接口的使用:
pthread_spinlock_t // 自旋锁类型 int pthread_spin_lock(pthread_spinlock_t *lock); // 抢不到锁一直等待 int pthread_spin_trylock(pthread_spinlock_t *lock); // 抢不到锁直接返回 int pthread_spin_unlock(pthread_spinlock_t *lock); // 解锁 int pthread_spin_init(pthread_spinlock_t *lock, int pshared); // 初始化,第二个参数通常设置为0 int pthread_spin_destroy(pthread_spinlock_t *lock); // 销毁
3. 自旋锁使用样例代码
使用自旋锁实现一个抢票逻辑的代码,如下:
#include <iostream> #include <pthread.h> #include <unistd.h> pthread_spinlock_t lock; int tickets = 1000; void* Routine(void* args) { const char* name = static_cast<const char*>(args); while (true) { pthread_spin_lock(&lock); if (tickets > 0) { usleep(1000); // 表示抢票时间 std::cout << name << " get a ticket, the remaining tickets are: " << tickets << std::endl; tickets--; pthread_spin_unlock(&lock); } else { pthread_spin_unlock(&lock); break; } } return nullptr; } int main() { pthread_spin_init(&lock, 0); pthread_t t1, t2, t3, t4; pthread_create(&t1, nullptr, Routine, (void*)"thread-1"); pthread_create(&t2, nullptr, Routine, (void*)"thread-1"); pthread_create(&t3, nullptr, Routine, (void*)"thread-1"); pthread_create(&t4, nullptr, Routine, (void*)"thread-1"); pthread_join(t1, nullptr); pthread_join(t2, nullptr); pthread_join(t3, nullptr); pthread_join(t4, nullptr); pthread_spin_destroy(&lock); return 0; }
测试结果如下:
读写锁
在编写多线程代码的时候,也会出现公共数据修改的机会较少的情况,也就是对于写入的数据,很少将其修改,既然不怎么修改,那么在读出数据的时候,也就是说我们根本就不需要加锁,不加锁多线程就可以并行运行。
1. 读者写者模型 vs 生产消费模型
在读者写者模型中和生产消费模型中的,存在什么区别呢?
其实读者写者模式和生产消费模型,都遵守321原则:三种关系、两个对象、一个场所(对于生产消费模型,在前文中已经介绍过,这里主要介绍读者写者模型)。
在读者写者模型中:三种关系为:读者和读者、写者和写者、读者和写者;
两个对象:读者和写者。
一个场所:可以是一段内存空间、也可以是某种数据结构。
让而在三种关系中,写者和写者的关系为互斥,一个时刻只能由一个线程写入信息,防止信息被覆盖,写者和读者的关系为互斥和同步关系,写的时候不能读,读的时候不可以写,但是当读完也需要通知写者来写,写完也需要通知读者来读;而对于读者和读者的关系则不同,读者和读者之间可以共享信息,可以一起读信息,互不干扰,所以读者和读者的关系为没有关系。
既然读者和读者之间没有关系,也就是说明在读消息的时候我们不需要加锁。
2. 读写锁伪代码和读写锁函数接口
一下为一个读写锁的伪代码,如下:
对于 Reader而言:
// 加锁 lock(count_lock); // 当第一个读者进来的时候,要将写者锁住 if(reader_count == 0) lock(writer_lock); ++reader_count; unlock(count_lock); //解锁 lock(count_lock); --reader_count; // 当是最后一个读者离开的时候,将写者唤醒,让写者写入信息 if(reader_count == 0) unlock(writer_lock); unlock(count_lock);
对于 Writer 而言:
lock(writer_lock); // write unlock(writer_lock);
对于读写锁的函数接口如下:
// 读写锁变量类型 pthread_rwlock_t // 初始化 int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,const pthread_rwlockattr_t *restrict attr); // 销毁 int pthread_rwlock_destroy(pthread_rwlock_t *rwlock) // 加锁和解锁 int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock); // 读者加锁 int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock); // 写者加锁 int pthread_rwlock_unlock(pthread_rwlock_t *rwlock); // 解锁通用 设置读写优先策略函数: int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t *attr, int pref); pref的三种取值: PTHREAD_RWLOCK_PREFER_READER_NP (默认设置) 读者优先,可能会导致写者饥 饿情况 PTHREAD_RWLOCK_PREFER_WRITER_NP 写者优先,目前有 BUG,导致表现行为和 PTHREAD_RWLOCK_PREFER_READER_NP 一致 PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP 写者优先,但写者不能递 归加锁
3. 读者优先和写者有限
读者优先:
在这种策略中,系统会尽可能多地允许多个读者同时访问资源,而不会优先考虑写者。这意味着当有读者正在读取时,新达到的读者会立即被运行进入到读取区,而写者会被阻塞,直到所有读者都离开读取区。读者优先策略可能会导致写者饥饿(写者长时间不能获取权限进入),特别是当读者频繁到达时。
写者优先:
在这种策略中,系统会优先考虑写者,当写者请求写入权限时,系统会尽可能地让写者进入写入区,即使此时有读者正在读取。意味着一旦有写者到达,所有后续的读者都会被阻塞,直到写者完成写入并离开写入区。写者优先策略可以减少写者等待的时间,但是会导致读者饥饿问题特别是写者频繁到达。
4. 读写锁使用样例代码
如下,我们使用读写锁给出一个代码样例:
#include <iostream> #include <pthread.h> #include <unistd.h> #include <ctime> #include <cstdlib> // 共享资源 int shared_data = 0; // 读写锁 pthread_rwlock_t rwlock; void* Reader(void* args) { // sleep(2); // 读者优先,一旦读者进入,写者就很难进入了 const char* name = static_cast<const char*>(args); while (true) { pthread_rwlock_rdlock(&rwlock); std::cout << name << " is reading the shared data, the data is " << shared_data << std::endl; sleep(1); // 模拟读花的时间 pthread_rwlock_unlock(&rwlock); } return nullptr; } void* Writer(void* args) { const char* name = static_cast<const char*>(args); while (true) { pthread_rwlock_wrlock(&rwlock); shared_data = rand() % 100; std::cout << name << " is writing the shared data, the data is " << shared_data << std::endl; sleep(1); // 模拟写花的时间 pthread_rwlock_unlock(&rwlock); } return nullptr; } int main() { srand(time(nullptr) ^ getpid()); pthread_rwlock_init(&rwlock, nullptr); const int reader_num = 2; const int writer_num = 2; const int total = reader_num + writer_num; pthread_t threads[total]; // 创建读线程 for (int i = 0; i < reader_num; i++) { char* buff = new char[128]; snprintf(buff, 128, "reader-%d", i + 1); pthread_create(&threads[i], nullptr, Reader, (void*)buff); } // 创建写线程 for (int i = reader_num; i < total; i++) { char* buff = new char[128]; snprintf(buff, 128, "writer-%d", i + 1); pthread_create(&threads[i], nullptr, Writer, (void*)buff); } for (int i = 0; i < total; i++) { pthread_join(threads[i], nullptr); } pthread_rwlock_destroy(&rwlock); return 0; }
测试结果如下:
对于如上代码,特别容易出现读者优先,可以通过调整写者的数量和读者进入的时间来调整为写者优先