01. 资源共享问题
1.1 多线程并发访问
例: 初始状态:counter=0
,线程 1 和 2 各自都执行counter++
操作
要想对counter++
做修改,在底层被编译成三条机器指令:
- 从内存加载counter的值到寄存器(load)
- 寄存器中的值加1(add)
- 将寄存器的值写回内存(store)
假设counter
初始值为0,在两个线程同时执行的时候,可能出现下面这种情况。以至于多线程场景中对全局变量并发访问不是 100%可靠的。
线程 1 执行:
- 从内存读取
counter=0
到寄存器。 - 寄存器中
counter+1=1
,未写回内存,就切换到另外一个线程。
线程 2 执行:
- 从内存读取
counter=0
(因线程 1 未更新内存)。 - 寄存器中
counter+1=1
,写回内存,此时counter=1
。
线程 1 恢复执行:
- 将寄存器中已计算的
1
写回内存,覆盖线程 2 的更新。
最终结果:counter=1
(预期应为 2)。
1.2 临界区与临界资源
- 临界资源:多线程执行流共享的资源就叫做临界资源
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区,例如上文中的
counter++
。 - 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
1.3 锁的引入
对于临界资源访问时的安全问题,也可以通过加锁来保证,实现多线程间的互斥访问,互斥锁就是解决多线程并发访问方法之一。
我们可以在线程1进入临界区之前加锁,出临界区之后解锁, 这样可以确保并发访问临界资源时的线性进行,若线程1在对共享资源进行操作时被切换成线程2,线程2也只能阻塞等待解锁。
注:
- 加锁、解锁是比较耗费系统资源的,会在一定程序上降低程序的运行速度
- 加锁后的代码是串行化执行的,势必会影响多线程场景中的运行速度
- 所以为了尽可能的降低影响,加锁粒度要尽可能的细
02. 多线程案例
2.1 为什么线程需要互斥?
当多个线程同时访问共享资源时,可能导致竞态条件,造成数据不一致或程序异常。但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。而多个线程并发的操作共享变量,会带来一些问题。线程互斥机制确保在任何时刻只有一个线程能访问共享资源。
#include <stdio.h> #include <pthread.h> int counter = 0; void* increment(void* arg) { for (int i = 0; i < 100000; i++) { counter++; // 非原子操作} return null;} int main() { pthread_t t1, t2; pthread_create(&t1, null, increment, null); pthread_create(&t2, null, increment, null); pthread_join(t1, null); pthread_join(t2, null); // 理论结果为:200000 printf("final counter: %d\n", counter); // 实际输出通常小于200000 return 0; }
在上面代码里面,我们知道counter
是临界资源,而increment
函数是访问临界资源的代码,亦称为临界区。
理想状态下是希望两个线程分别对counter
加100000
次。但是由由于非原子操作和内存可见性问题,当两个线程同时执行这些指令,可能会出现指令交错,导致最终结果通常会小于预期200000
。
要解决以上问题,需要做到三点:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。linux上提供的这把锁叫互斥量
2.2 线程或进程切换时机?
- 时间片耗尽时
- 有更高优先级的进程要调度时
- 通过sleep,从内核返回用户时,会进行时间片是否到达的检测,进而导致切换
如果锁对象是全局的或静态的,可以用宏:pthread_mutex_initializer
初始化,并且不用我们主动destroy
;如果锁对象是局部的,需要用pthread_mutex_init
初始化,用pthread_mutex_destroy
释放。
- 所有对资源的保护,都是对临界区代码的访问,因为资源都是通过代码访问的。
- 要保证加锁的细粒度。
- 加锁就是找到临界区,对临界区进行加锁。
那么相应的又有一些问题:
- 锁也是全局的共享资源,谁保证锁的安全?加锁和解锁被设计为原子的。
- 如果看待锁?加锁本质就是对资源的预定工作,整体使用资源,所以加锁前先要申请锁。
- 如果申请锁的时候,锁已经被别的线程拿走了怎么办?其他线程阻塞等待。
- 线程在访问临界区的时候,可不可以被切换?可以,我被切走,其他线程也不能进来,因为我走的时候是带着锁走的,保证了原子性。
03. 线程互斥
3.1 互斥锁操作
有以下特点:
- 最简单的同步原语
- 只有"锁定"和"未锁定"两种状态
- 同一时间只允许一个线程持有锁
// 初始化(静态) pthread_mutex_t mutex = pthread_mutex_initializer; // 初始化(动态) int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr); // 加锁/解锁 int pthread_mutex_lock(pthread_mutex_t *mutex); int pthread_mutex_unlock(pthread_mutex_t *mutex); // 销毁 int pthread_mutex_destroy(pthread_mutex_t *mutex);
3.2代码互斥问题优化
通过对上面代码进行改进,我们便可以得到正确的结果。
细节: 互斥会给其他线程带来影响
当某个线程持有[锁资源】 时,对于其他线程的有意义的状态:在这两种状态的划分下,确保了多线程并发访问时的 原子性
- 锁被我申请了(其他线程无法获取)
- 锁被我释放了(其他线程可以获取锁)
3.3 互斥锁原理
lock
是原子的,其他线程无法进入。 为了实现互斥锁操作,大多数体系结构都提供了swap
或exchange
指令,该指令的作用是把寄存器和内存单元的数据交换(私有和共享),由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
3.4 多线程封装
着手编写一个小组件: demo 版线程库目标:对 原生线程库 提供的接口进行封装,进一步提高对线程相关接口的熟练程度既然是封装,这里的类成员包括:
- 线程
id
- 线程名
name
- 线程状态
status
- 线程回调函数
fun t
- 传递给回调函数的参数
args
3.4.1 thread.hpp编写
#pragma once #include <iostream> #include <string> #include <pthread.h> #include <cassert> // 参数、返回值为 void 的函数类型 typedef void *(*func_t)(void *); const int num = 1024; class thread { public: thread(func_t func, void *args = nullptr, int number = 0) : _func(func), _args(args) { // 根据编号写入名字 char buf[128]; snprintf(buf, sizeof buf, "thread-%d", num); _name = buf; int n = pthread_create(&_tid, nullptr, runhelper, this); // this->thread* assert(n == 0); (void)n; } // 回调方法 static void *runhelper(void *args) { thread *_this = static_cast<thread *>(args); return _this->callback(); } // 获取 id pthread_t gettid() const { return _tid; } // 获取线程名 std::string getname() const { return _name; } // 启动线程 void run() { int ret = pthread_create(&_tid, nullptr, runhelper, this );//this 是一个指向当前类类型的常量指针 if (ret != 0) { std::cerr << "create thread fail!" << std::endl; exit(1); // 创建线程失败,直接退出 } } // 线程等待 void join() { int ret = pthread_join(_tid, nullptr); if (ret != 0) { std::cerr << "thread join fail!" << std::endl; exit(1); // 等待失败,直接退出 } } void *callback() { // 亦指在外调用的线程处理函数,_args与是否返回值有关 return _func(_args); } private: pthread_t _tid; // 线程 id std::string _name; // 线程名 func_t _func; // 线程回调函数 void *_args; // 传递给回调函数的参数 };
测试代码:
#include "thread.hpp" // 1:线程创建和运行 void *basic_task(void *arg){ int *val = static_cast<int *>(arg); std::cout << "线程正在运行,初始值为: " << *val << std::endl; *val *= 2; // 修改传入的值 return nullptr;} // 2:带返回值 void *task_with_return(void *arg){ std::string *msg = new std::string("hello!"); return msg;} int main(){{ int value = 42; thread t1(basic_task, &value); t1.join(); std::cout << "修改后旳值为: " << value << std::endl; // 应该输出84} std::cout << "---------------: " << std::endl;{ thread t2(task_with_return); void *ret_val = nullptr; pthread_join(t2.gettid(), &ret_val); // 直接使用pthread_join获取返回值 if (ret_val){ std::string *msg = static_cast<std::string *>(ret_val); std::cout << *msg << std::endl; // 输出线程返回的消息 delete msg; // 记得释放内存 }} return 0;}
结果如下:
3.5 互斥锁封装
我们对锁进行封装,实现一个简单易用的小组件。利用创建对象时调用构造函数,对象生命周期结束时调用析构函数的特点,融入加锁、解锁等操作。更加方便
#pragma once #include <iostream> #include <pthread.h> class mutex { public: mutex(const mutex &) = delete; const mutex &operator=(const mutex &) = delete; mutex(){ int n = pthread_mutex_init(&_lock, nullptr); } void lock(){ int n = pthread_mutex_lock(&_lock); } void unlock(){ int n = pthread_mutex_unlock(&_lock); } pthread_mutex_t *lockptr() { return &_lock; } ~mutex(){ int n = pthread_mutex_destroy(&_lock); } private: pthread_mutex_t _lock; }; class lockguard{ public: lockguard(mutex &mutex) : _mutex(mutex){ _mutex.lock(); } ~lockguard(){ _mutex.unlock(); } private: mutex &_mutex; // 在该类下面定义了一个mutex类型的引用成员变量,_mutex为变量名 };
3.5.1 raii风格
像这种获取资源即初始化的风格称为raii风格
,非常巧妙的运用了类和对象的特性,实现半自动化操作。
04. 线程同步
当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。例如:一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。
同步概念与竞态条件:
- 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
- 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解
4.1 死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
4.1.1 死锁四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
4.1.2 避免死锁
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
4.1.3 避免死锁算法
- 死锁检测算法(了解)
- 银行家算法(了解
4.2 条件变量
条件变量是线程同步的高级机制,用于解决"等待特定条件成立"的场景。它总是与互斥锁配合使用,实现高效的线程等待-通知机制。有以下特点:
- 总是与互斥锁配合使用
- 解决"等待-通知"问题
- 避免忙等待(busy-waiting)
操作代码:
// 初始化 pthread_cond_t cond = pthread_cond_initializer; // 等待条件满足(自动释放关联互斥锁) int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex); // 通知条件 int pthread_cond_signal(pthread_cond_t *cond); // 唤醒一个线程 int pthread_cond_broadcast(pthread_cond_t *cond); // 广播。。唤醒所有线程
可以把条件变量
看作一个结构体,其中包含一个队列结构,用来存储正在排队等候的线程信息,当条件满足时,就会取 队头 线程进行操作,操作完成后重新进入队尾。后续基于此实现生产者-消费者模型。
简单使用示例:
#include <pthread.h> #include <stdio.h> pthread_mutex_t mutex = pthread_mutex_initializer;//静态初始化 pthread_cond_t cond = pthread_cond_initializer; int data_ready = 0; // 共享条件 void* consumer(void* arg) { pthread_mutex_lock(&mutex); while (data_ready == 0) { printf("consumer: waiting...\n"); pthread_cond_wait(&cond, &mutex); // 阻塞并释放锁 } printf("consumer: processing data.\n"); data_ready = 0; pthread_mutex_unlock(&mutex); return null; } void* producer(void* arg) { sleep(1); // 模拟数据准备时间 pthread_mutex_lock(&mutex); printf("producer: data ready.\n"); data_ready = 1; pthread_cond_signal(&cond); // 唤醒消费者 pthread_mutex_unlock(&mutex); return null; } int main() { pthread_t tid1, tid2; pthread_create(&tid1, null, consumer, null); pthread_create(&tid2, null, producer, null); pthread_join(tid1, null); pthread_join(tid2, null); return 0; }
总结
以上为个人经验,希望能给大家一个参考,也希望大家多多支持代码网。
发表评论