目录
线程概念
在 Linux 下,线程(Thread)是一种轻量级的进程(Lightweight Process,LWP),它共享相同的内存空间和资源,但可以独立执行。Linux 下的线程由内核线程(Kernel Thread)实现,内核线程是内核调度的基本单元。
线程是一个进程内部的控制序列。线程是在进程内部运行,本质上在进程地址空间内运行。线程创建后生成一个task_struct,多个task_struct在底层使用链表的形式串联在一起。多线程共用同一张进程页表,通过该页表访问物理内存中的数据。
Linux在用户层实现多线程方案,以库的形式提供给用户使用。
进程通过虚拟地址空间可以获取物理内存上存放的资源。将进程所拥有的资源合理分配给每个执行流,这样就形成了线程的执行流。
线程的特性
- 共享资源:线程共享进程的地址空间、全局变量、文件描述符等资源。
- 独立栈:每个线程有自己的栈,用于存储局部变量和函数调用信息。
- 轻量级:线程创建和上下文切换的开销较小,适用于高并发场景。
NPTL (Native POSIX Thread Library) 是 Linux 系统上实现 POSIX 线程 (pthread) 的一种线程库。它是 Linux 系统中主要的线程库之一,被设计用来替代旧的 LinuxThreads 实现,以提供更好的性能和 POSIX 兼容性。
- 高性能和可扩展性:NPTL 被设计为高性能线程库,能够在多处理器系统上高效运行。
- 完全 POSIX 兼容:NPTL 提供了更好的 POSIX 兼容性,解决了 LinuxThreads 实现中的一些不兼容问题。
- 快速线程创建和销毁:相比 LinuxThreads,NPTL 提供了更快的线程创建和销毁速度。
- 改进的信号处理:NPTL 改进了信号处理机制,支持每个线程都有自己的信号掩码和信号处理函数。
- 与 glibc 的集成:NPTL 与 GNU C 库 (glibc) 紧密集成,为用户提供了无缝的 pthread 编程体验。
Linux使用线程轻量化原因
地址空间和页表之间不需要频繁切换。线程的实现时在一个进程下创建了多个线程,线程和进程两者共用一个页表空间。
不需要反复刷新CPU缓存。进程切换的时候,CPU中的缓存需要频繁的清空,重新运行新进程的缓存。线程的实现只需要运行一个进程,所以不需要反复的刷新缓存,从而提高效率。
虚拟地址转换为物理地址
缺页中断,操作系统中的一种异常,当进程试图访问的内存页不在物理内存中时就会发生缺页中断。缺页中断发生后由操作系统负责进行处理,操作系统的内存管理单元将所需要的内存页从硬盘加载到物理内存中,从而使得进程能够继续执行。
缺页中断过程分析
- 进程访问内存:进程试图访问某个虚拟地址。
- 查找页表:处理器通过查找页表来确定该虚拟地址是否在物理内存中。
- 页不在内存:如果页表条目指示该页不在物理内存中,则产生缺页中断。
- 陷入内核:处理器陷入内核模式,并调用缺页中断处理程序。
- 分配物理页:操作系统找到一个空闲的物理页。如果没有空闲页,操作系统可能需要使用页替换算法将某个页换出到磁盘。
- 更新页表:将所需的页从磁盘加载到物理内存中,并更新页表条目,使其指向新的物理页。
- 恢复进程:将控制权返回给进程,并重新执行导致缺页中断的指令。
缺页中断的优缺点
优点
- 有效利用内存:通过虚拟内存机制,可以使进程使用比实际物理内存更大的地址空间。
- 内存保护:每个进程都有自己的虚拟地址空间,互不干扰,增加了系统的稳定性和安全性。
- 内存共享:多个进程可以共享同一个物理内存页(例如,代码段),节省内存。
缺点
- 性能开销:缺页中断处理涉及磁盘 I/O,可能会导致较大的性能开销。
- 复杂性:实现虚拟内存和缺页中断处理需要复杂的硬件和软件支持。
虚拟内存寻找物理内存的过程,程序运行后代码开始执行,代码找到磁盘中的可执行文件,磁盘运行可执行文件,磁盘将页帧放到页框中,然后找到物理空间的具体数据。
磁盘中形成的可执行程序的按照4kb(页帧)划分然后存放在磁盘中;物理内存的区域同样按照4kb划分,该块区域称为页框。IO的主要过程就是将页帧放进到页框中,基本单位即是4Kb。物理内存中存储一个用来描述页框中哪些区域中没有使用的结构体。
虚拟地址转换为物理地址过程分析
- 生成虚拟地址
- 程序访问内存的时候,CPU生成虚拟地址给程序使用,该进程在使用内存时,都是在这块操作系统生成的虚拟地址空间中进行。
- 页表查找
- 利用虚拟地址查找页表(存储虚拟地址与物理地址之间的映射关系),确定对应的物理地址。
- 页表项
- 有效位(Present bit):指示该页是否在物理内存中。
- 页帧号(Page Frame Number,PFN):物理内存中的页帧号。
- 访问权限(Permission bits):指示该页的读、写、执行权限。
- 其他标志位,如修改位(Dirty bit)、访问位(Accessed bit)等。
- 转换
- 找到页目录表
- 通过页目录表找到对应的页表
- 通过页表项即可找到物理地址
线程的优缺点
优点
- 线程的创建代价比线程创建代价小
- 线程切换花费更少的时间
- 线程比进程占用较少资源
- 充分利用多处理器的可并行数量
- 等待IO操作时可以执行其他任务
- 线程可以同时等待不同的IO操作
缺点
- 性能损失:计算密集型的线程数量比可用的处理器多时,可能会有较大的性能损失,即会增加额外的同步开销,而可用资源不变
- 增加编程难度
- 健壮性降低:线程之间缺乏保护
- 缺乏访问控制:两个进程如果不加控制的去访问同一块内存空间时,则会造成进程冲突
线程异常和线程用途
线程异常:线程如果出现例如除零、野指针问题导致线程崩溃,进程也会跟随崩溃。线程是进程的执行分支,如果线程出现异常,类似与进程出现异常,此时会触发信号机制,终止进程,进程终止,相应的会导致所有线程都跟随退出。
线程用途
- 提高CPU密集型程序的执行效率
- 提高IO密集型程序的用户体验
进程与线程的关系
进程是资源分配的基本单位,线程是调度的基本单位,线程共享进程数据,同时线程也有自己的数据,例如线程ID、寄存器(线程上下文)、栈、信号屏蔽字、调度优先级。
各个线程共享进程的资源和环境,文件描述符、信号处理方法、工作目录、用户ID和组ID
进程和线程的关系主要有单线程进程、单进程多线程、多个单线程进程、多个多线程进程
线程独享内存区域
多线程共享一块内存空间,如何保证栈区中每个线程拥有独享空间?
- 每个线程都有自己独立的栈空间,这些栈空间由操作系统分配,并在内存中连续分布。虽然栈空间在同一个虚拟地址空间中,但它们彼此独立,不会互相干扰。
- 线程上下文:每个线程有自己的上下文,包括栈指针和程序计数器,保证线程在执行时使用自己独立的栈空间。
- 内存保护:操作系统利用内存保护机制,防止线程访问其他线程的栈空间。
- 栈大小设置:默认情况下,操作系统为每个线程分配默认大小的栈空间。通过
pthread_attr_setstack
函数,可以自定义线程的栈大小和栈地址。
线程创建后的存储位置以及线程的获取
- 进程调用线程库,线程库在进程的共享区中展开,地址空间通过页表映射到物理空间
- 线程库调用到该进程共享区的物理空间中,所以线程和进程通过地址空间可以使用线程相应的操作
- 操作系统需要对线程进行集中管理,数据结构的组织则是由线程库完成,线程创建后以链表的方式存放在共享区中,如果想要使用该线程,则需要pthread_self函数获取共享区中的线程ID,从而判断线程所在位置。
控制线程
创建线程
多线程的父进程ID相同
- 进程终结后,进程下的所有线程也会随之终结
- 程序中的代码被线程和进程所共享,堆区的数据也是被共享,但是每个线程创建的堆区只能够被该线程自己访问
线程创建异常
- 线程的执行顺序是取决于调度器,线程出现异常时候,可能会导致进程的整体退出
- 线程创建后,进程需要等待,进程需要掌握线程的情况,防止线程在进程内部搞破坏
等待线程
线程等待的原因
- 已经退出的线程,该线程空间可能没有被及时被释放,仍存在进程地址空间中
- 新的创建进程不会去使用刚才退出的地址空间,容易造成地址泄漏
- 协调线程之间的工作,避免资源竞争和提高程序的效率
线程等待使用的场景
- 线程等待IO操作例如文件读写或者网络通信的完成
- 等待互斥锁,防止资源竞争
- 等待条件变量,通过条件变量实现线程同步
- 等待线程完成,主线程或者其他线程等待某个特定的线程完成
- 等待信号量,信号量用来控制公共资源的访问,线程则等待信号量何时变的可用
pthread_join() 方法阻塞当线程,直到被调用的线程完成其任务
#include <iostream>
#include <thread>
void threadFunction() {
std::cout << "Thread is running...\n";
std::this_thread::sleep_for(std::chrono::seconds(2));
std::cout << "Thread has finished.\n";
}
int main() {
std::thread t(threadFunction);
std::cout << "Main thread is waiting for child thread to finish...\n";
t.join(); // 等待线程 t 终止
std::cout << "Child thread has finished, main thread resumes.\n";
return 0;
}
终止线程
使用一个共享的布尔变量通知线程终止,常用在多线程的项目上
#include <iostream>
#include <thread>
#include <atomic>
#include <chrono>
// 全局原子布尔变量,用于通知线程终止
std::atomic<bool> stopFlag(false);
// 线程函数
void worker() {
while (!stopFlag.load()) {
std::cout << "Thread is running..." << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
}
std::cout << "Thread is stopping..." << std::endl;
}
int main() {
// 启动线程
std::thread t(worker);
// 主线程等待 5 秒
std::this_thread::sleep_for(std::chrono::seconds(5));
// 设置标志位,通知线程终止
stopFlag.store(true);
// 等待线程终止
t.join();
std::cout << "Thread has been stopped." << std::endl;
return 0;
}
条件变量终止进程。 互斥锁用来保护共享变量stopFlag每一次只可以被一个线程访问到,条件变量则是通知其他线程(下列代码的作用是用来唤醒其它线程);根据判断标志位判断线程是否终止。
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <chrono>
std::mutex mtx;
std::condition_variable cv;
bool stopFlag = false;
void worker() {
std::unique_lock<std::mutex> lock(mtx);
while (!stopFlag) {
cv.wait_for(lock, std::chrono::seconds(1), [] { return stopFlag; });
std::cout << "Thread is running...\n";
}
std::cout << "Thread is stopping...\n";
}
int main() {
std::thread t(worker);
std::this_thread::sleep_for(std::chrono::seconds(5));
{
std::lock_guard<std::mutex> lock(mtx);
stopFlag = true; // 通知线程终止
}
cv.notify_all(); // 唤醒所有等待的线程
t.join();
std::cout << "Thread has been stopped.\n";
return 0;
}
pthread_exit() 终止进程
#include <iostream>
#include <pthread.h>
// 线程函数
void* threadFunc(void* arg) {
std::cout << "Thread is running...\n";
pthread_exit((void*) 42); // 线程退出并返回 42
}
int main() {
pthread_t thread;
void* threadResult;
// 创建线程
if (pthread_create(&thread, nullptr, threadFunc, nullptr)) {
std::cerr << "Error creating thread\n";
return 1;
}
// 等待线程完成
if (pthread_join(thread, &threadResult)) {
std::cerr << "Error joining thread\n";
return 1;
}
std::cout << "Thread exited with code " << (long)threadResult << std::endl;
return 0;
}
- 线程函数
threadFunc
输出一条消息,然后调用pthread_exit
并返回值 42。pthread_exit
的参数是一个void*
类型的指针,可以传递任意类型的值。pthread_create
创建一个新线程并执行threadFunc
。pthread_join
等待线程完成,并获取线程的退出码。- 资源释放: 调用
pthread_exit
会自动清理线程的资源,但不会退出进程。如果这是主线程,主线程的退出不会导致整个进程退出。要让进程退出,可以调用exit
函数。- 线程返回值:
pthread_exit
的返回值可以被pthread_join
获取。如果线程没有显式调用pthread_exit
,那么线程的返回值就是线程函数的返回值。- 线程取消: 线程也可以通过
pthread_cancel
被其他线程取消。被取消的线程会调用它的清理处理程序,并像调用pthread_exit
一样终止。
主线程退出,其他线程正常运行。主函数中调用pthread_exit即可实现。
pthread_cancel()
- 请求取消一个线程,被请求取消的线程可以选择如何响应取消请求,该线程可以选择忽略该请求。
不同终止线程的方法对线程等待的影响
正常退出:线程函数正常执行完返回,
pthread_join
会成功返回,等待线程会继续执行。
异常退出:现成函数抛出未捕获的异常,则线程终止,并且
pthread_join
会成功返回,但通常不会有其他信息来指示异常的存在。
pthread_exit:线程可以显式调用
pthread_exit
终止自己,这会立即终止线程的执行,且不会影响等待线程的行为
ptrhead_cancel(): 另一个线程可以调用
pthread_cancel
请求取消目标线程。如果目标线程是可取消的,并且在取消点检查到取消请求,它将终止执行。pthread_join
会成功返回,但必须处理好目标线程的清理工作
进程ID和线程ID的关系
-
线程ID(PID)
- 定义:PID 是操作系统分配给每个进程的唯一标识符。
- 作用:用于唯一标识一个进程,操作系统通过 PID 管理和调度进程。
- 范围:在同一系统中,每个进程的 PID 是唯一的。
- 线程ID(TID)
- 定义:TID 是操作系统分配给每个线程的唯一标识符。
- 作用:用于唯一标识一个线程,操作系统通过 TID 管理和调度线程。
- 范围:在同一系统中,每个线程的 TID 是唯一的。一个进程中的线程共享同一个 PID,但每个线程有一个唯一的 TID
- 两者关系
- 单线程进程:在一个只有一个线程的进程中,PID 和 TID 通常是相同的。
- 多线程进程:在一个有多个线程的进程中,所有线程共享同一个 PID,但每个线程都有一个唯一的 TID。
- 系统调用:在许多操作系统中,可以通过系统调用获取进程的 PID 和线程的 TID。例如,在 Linux 中,可以使用
getpid()
获取当前进程的 PID,使用pthread_self()
获取当前线程的 TID。
线程组
- 线程组存在的意义,Linux每个进程下可以携带多个线程,为了管理并分配给这些线程响应的资源,所以采用线程组对线程进行集中管理。
- 内核中规定第一个线程TID则是主线程,进程之间存在父子关系,线程之间全部是对等关系。
#include <iostream>
#include <vector>
#include <thread>
#include <functional>
#include <algorithm>
class ThreadGroup {
public:
// 启动一个新的线程,并将其添加到线程组中
void addThread(std::function<void()> func) {
threads.emplace_back(std::thread(func));
}
// 等待所有线程完成
void joinAll() {
for (auto& t : threads) {
if (t.joinable()) {
t.join();
}
}
}
// 终止所有线程(注意:这是不安全的示例,不推荐在实际代码中使用)
void detachAll() {
for (auto& t : threads) {
if (t.joinable()) {
t.detach();
}
}
}
// 清理所有线程
void clear() {
threads.clear();
}
// 获取线程数量
size_t size() const {
return threads.size();
}
private:
std::vector<std::thread> threads;
};
void printMessage(const std::string& message) {
std::cout << message << std::endl;
}
int main() {
ThreadGroup tg;
tg.addThread([] { printMessage("Thread 1 is running..."); });
tg.addThread([] { printMessage("Thread 2 is running..."); });
tg.addThread([] { printMessage("Thread 3 is running..."); });
std::cout << "All threads have been started." << std::endl;
tg.joinAll();
std::cout << "All threads have finished." << std::endl;
return 0;
}
Linux查看进程和线程htop
命令htop
线程ID及线程地址空间
线程私有空间
栈:线程拥有属于自己的栈空间,用于存储局部变量、函数调用的返回地址和变量等信息以及函数调用过程中的临时数据
- 栈大小:可以通过编译时设置或在运行时通过系统调用
pthread_attr_setstacksize
设置。- 栈增长:栈通常向低地址方向增长。
#include <iostream>
#include <pthread.h>
void* threadFunc(void* arg) {
int localVar = 42;
std::cout << "Thread local variable address: " << &localVar << std::endl;
return nullptr;
}
int main() {
pthread_t thread;
if (pthread_create(&thread, nullptr, threadFunc, nullptr)) {
std::cerr << "Error creating thread" << std::endl;
return 1;
}
pthread_join(thread, nullptr);
return 0;
}
线程共享内存空间
- 堆(Heap):用于动态内存分配。线程可以使用
malloc
和free
等函数在堆上分配和释放内存。- 代码段(Code Segment):存储程序的可执行代码。
- 数据段(Data Segment):存储全局变量和静态变量,包括已初始化和未初始化的变量。
- 线程的共享空间需要进行同步,避免线程在访问共享资源的过程中发生竞争。
pthread_t 类型分析
- 具体实现什么类型取决于具体实现,在Linux中是一个进程地址空间上的一个地址
- 地址指向的函数作用是找到线程ID,然后让该线程进行相应的操作
线程分离
线程分离能够让线程在完成其工作后自动释放资源,而不需要去调用pthread_join去等待线程终止。分离线程后的线程是独立的,其生命周期独立于创建它的线程。
线程分离使用场景分析
- 线程完成状态不需要被其他线程等待或者关心的时候,可以使用分离线程。(确保分离的线程和其他线程没有纠缠不清的关系)
- 分离线程用于后台任务或者不太重要的任务,确保资源在线程完成的时候可以自动释放。
- 线程分离后,独立后线程资源的释放由系统进行
创建线程时设置分离属性
#include <iostream>
#include <pthread.h>
#include <unistd.h>
// 线程函数
void* threadFunc(void* arg) {
std::cout << "Thread is running..." << std::endl;
sleep(2); // 模拟工作
std::cout << "Thread has finished." << std::endl;
return nullptr;
}
int main() {
pthread_t thread;
pthread_attr_t attr;
// 初始化线程属性对象
pthread_attr_init(&attr);
// 设置线程为分离状态
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
// 创建线程
int result = pthread_create(&thread, &attr, threadFunc, nullptr);
if (result != 0) {
std::cerr << "Error creating thread: " << result << std::endl;
return 1;
}
// 销毁线程属性对象
pthread_attr_destroy(&attr);
// 主线程继续执行,不等待子线程完成
std::cout << "Main thread is continuing..." << std::endl;
sleep(3); // 保证主线程不会立即结束
std::cout << "Main thread has finished." << std::endl;
return 0;
}
线程创建后调用pthread_detach
#include <iostream>
#include <pthread.h>
#include <unistd.h>
// 线程函数
void* threadFunc(void* arg) {
std::cout << "Thread is running..." << std::endl;
sleep(2); // 模拟工作
std::cout << "Thread has finished." << std::endl;
return nullptr;
}
int main() {
pthread_t thread;
// 创建线程
int result = pthread_create(&thread, nullptr, threadFunc, nullptr);
if (result != 0) {
std::cerr << "Error creating thread: " << result << std::endl;
return 1;
}
// 将线程设置为分离状态
result = pthread_detach(thread);
if (result != 0) {
std::cerr << "Error detaching thread: " << result << std::endl;
return 1;
}
// 主线程继续执行,不等待子线程完成
std::cout << "Main thread is continuing..." << std::endl;
sleep(3); // 保证主线程不会立即结束
std::cout << "Main thread has finished." << std::endl;
return 0;
}
线程互斥
进程线程间互斥相关概念
临界资源
多线程共享情况下,只允许一个执行流访问的资源就是临界资源
多线程可以同时访问点的共享资源就是临界资源
临界区
访问临界资源的一段代码
互斥
确保临界区代码在任意时刻只可以被一个线程访问
通常使用互斥锁实现,用互斥锁保护临界区,确保在同一时刻只有一个线程进入临界区访问资源
原子性
操作的不可分割性,操作要么全部执行完成,要么完全不执行,不存在中间状态
C++11标准中的原子类型std::atomic
事例:fetch_add是一个原子操作,保证了对atomicResourece的加法操作是不可分割的
互斥量mutex
互斥量存在的原因
- 防止数据竞争
- 多个线程同时读写共享数据时,没有设置同步机制,则可能会导致多个线程重复的拿取相同的数据,最终的结果是不可预测的
- 确保数据一致性
- 使用互斥量,确保同一时刻只有一个线程可以进入临界区中,从而防止数据竞争,确保数据的一致性
- 防止死锁
- 互斥量如果使用不当的话会导致死锁现象的产生
- 死锁:两个或者多个线程互相等待对方释放资源,从而导致所有线程都无法继续执行
互斥的特点
- 独占访问
- 互斥量确保在任意时刻只有一个线程可以进入临界区访问共享资源,从而防止多个线程同时修改共享资源导致的数据竞争不一致的行为
- 同步
- 互斥量是一种同步机制,可以用于协调线程之间操作顺序。互斥量可以强制线程按照某种顺序执行,确保线程间的同步
- 阻塞与非阻塞
- 阻塞:一个线程尝试获取已被锁定的互斥量时,该线程被阻塞,直到互斥量被释放为止,通过该机制确保只有一个线程进入临界资源
- 非阻塞:线程如果判断互斥量已经被锁定,则立刻返回去做其他事情,不会阻塞在那里等待
- 递归锁定
- 允许同一个线程多次锁定同一个互斥量,每次锁定操作必须对应一个解锁操作,直到完全解锁。
互斥量初始化
静态初始化,用于全局变量或者静态存储生命周期的互斥锁。使用宏
PTHREAD_MUTEX_INITIALIZER
将pthread_mutex_t
初始化为默认属性的互斥锁。
#include <pthread.h>
#include <stdio.h>
// 静态初始化
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
void* threadFunc(void* arg) {
pthread_mutex_lock(&mtx);
printf("Thread %ld has locked the mutex\n", (long)arg);
pthread_mutex_unlock(&mtx);
return NULL;
}
int main() {
pthread_t threads[2];
for (long i = 0; i < 2; ++i) {
pthread_create(&threads[i], NULL, threadFunc, (void*)i);
}
for (int i = 0; i < 2; ++i) {
pthread_join(threads[i], NULL);
}
return 0;
}
动态初始化,pthread_mutex_init ,指定要初始化的对象同时还可以指定默认传递的属性
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
// 互斥锁对象
pthread_mutex_t mtx;
void* threadFunc(void* arg) {
pthread_mutex_lock(&mtx);
printf("Thread %ld has locked the mutex\n", (long)arg);
pthread_mutex_unlock(&mtx);
return NULL;
}
int main() {
pthread_t threads[2];
int ret;
// 动态初始化互斥锁
ret = pthread_mutex_init(&mtx, NULL);
if (ret != 0) {
perror("pthread_mutex_init");
exit(EXIT_FAILURE);
}
for (long i = 0; i < 2; ++i) {
pthread_create(&threads[i], NULL, threadFunc, (void*)i);
}
for (int i = 0; i < 2; ++i) {
pthread_join(threads[i], NULL);
}
// 销毁互斥锁
pthread_mutex_destroy(&mtx);
return 0;
}
销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);
- mutex:需要销毁的互斥锁对象的指针
- 返回值:成功返回0
- 销毁前检查:互斥锁销毁前需要确保互斥锁没有被其他线程所使用
- 销毁后行为:销毁互斥锁后,该互斥锁对象不可以再用于锁定操作
- 避免互斥锁的重复销毁
互斥量原理理解
线程进入临界区后,操作系统仍然会进行线程切换
线程持有互斥锁并在临界区中执行的时候,调度程序也可能会将该线程挂起并切换到另一个线程
- 操作系统的线程调度
- 操作系统的调度程序负责管理多个线程的执行,确保每个线程都获得CPU时间。这种调度是抢占式的,所以这就是说调度程序可以在任意时刻中断当前正在执行的线程,并将其上下文保存在内存中,然后切换到另一个线程。
- 线程调度出现的原因,线程的时间片用完、线程调用阻塞状态调用、线程进入等待状态、更高优先级的线程执行等。
- 临界区和互斥锁
- 互斥锁用于保护临界区,确保同一时刻只有一个线程可以进入临界区访问共享资源。
- 互斥锁的主要目的是防止数据竞争,保证临界资源数据的一致性,不用于防止线程切换。
- 线程切换和互斥锁
- 综上两条分析,即使线程拥有互斥锁的情况下仍然会发生线程切换
- 线程切换后持有锁的线程是否会释放锁分析
- 线程释放锁可能出现情况分析
- 时间片耗尽:操作系统给线程分配的时间片耗尽时调度程序会切换到另一个线程
- 高优先级线程:当出现比当前线程更高优先级的线程时,操作系统则会切换让高优先级线程去执行。
- 持有锁的线程切换后,其他线程会继续阻塞等待,直到持有锁的线程执行完临界区代码后才能够释放锁让其他线程使用。
确保线程及时释放锁的方法
- 减少临界区的代码量:让线程获取锁后可以快速完成其操作,从而保证锁尽早释放
- 避免阻塞操作:减少在临界区的阻塞操作,因为大量的IO会浪费大量的时间
- 合理选择锁的机制:选择适合项目的锁类型
加锁后临界资源代码串行执行
- 临界区内代码串行执行
- 因为同一时刻只有一个线程持有互斥锁,并运行临界区中的代码
- 临界区外代码并行
- 临界区外的代码不受互斥锁的影响,所以多个线程可以并行执行
- 串行和并行如何达到平衡
- 最小化临界区:尽量减少临界区的代码量,使锁的持有时间尽可能短,提升并行性能。
- 避免长时间阻塞:避免在临界区内执行长时间阻塞操作,如 I/O 操作或复杂计算,以免影响其他线程的并行执行。
互斥量本身是共享资源
互斥量本身是一种共享资源,互斥量本身需要在多个线程之间共享从而实现其同步功能
- 操作系统层面,互斥量包括一个锁定标志和一个等待队列
- 线程尝试锁定互斥量时,操作系统检查该锁定标志
- 如果互斥量未被锁定,操作系统将锁定它,并允许线程进入临界区
- 如果互斥量已经被锁定,操作系统则将该线程放入等待队列,并阻塞该线程直到互斥量被释放
- 当持有互斥量的线程释放锁的时候,操作系统会从等待队列中唤醒一个或多个等待线程,并允许它们去获取锁
汇编角度理解互斥锁
X86架构下,指令xchg 和 cmpxchg指令利用硬件支持的原子操作,确保在多处理器环境下对共享数据进行安全访问
- xchg 指令
xchg
(exchange)指令用于交换两个操作数的值。该指令保证交换操作是原子的,即在交换过程中不会被其他指令中断。xchg dest, src
- dest:目标操作数,可以是寄存器或内存位置。
- src:源操作数,可以是寄存器。
cmpxchg指令
cmpxchg
(compare and exchange)指令用于比较和交换操作,常用于实现锁和其他同步原语。该指令首先比较目标操作数与累加器(通常是EAX
寄存器)的值,如果相等,则将源操作数的值存储到目标操作数中;如果不相等,则将目标操作数的值加载到累加器中。
互斥量的安全性如何保证
- 硬件层面
- 原子操作:现代处理器提供多种原子操作指令,保证操作的不可分割性
- 操作系统
- 内核:操作系统通过提供线程同步例如互斥量、信号量和条件变量来实现互斥量在多线程环中的正确性
- 互斥量的实现
- 程序设计
- 正确的使用互斥量
- 正确初始化互斥量;在访问共享资源之前加互斥量,访问共享资源后释放互斥量
可重入和线程安全
可重入
可重入表示一个函数可以在被另一个调用中断之后再被安全调用的特征(多个执行流可以进入同一个函数)。可重入函数在执行的过程中可以被中断,并在任何时刻被另一个相同或者不同的线程再次调用不会产生数据不一致的问题
- 不使用静态或全局变量:可重入函数不依赖于函数外部的静态或全局变量,因为这些变量在多次调用时可能会产生竞态条件。
- 不返回静态或全局数据:返回静态或全局数据会导致数据竞争。
- 使用局部变量:所有的数据应存储在局部变量或通过参数传递。
- 不调用不可重入函数:如果一个函数调用了不可重入的函数,它自身也无法保证可重入性。
- 相反可以退出不可重入的特点
线程安全
线程安全表示的是一个函数可以被多个线程并发调用而不会引发数据不一致的问题。
- 使用互斥锁:通过互斥锁(如
std::mutex
)保护共享资源,确保同一时间只有一个线程可以访问临界区。- 避免共享数据:尽量避免使用共享数据,使用线程本地存储或传递参数。
- 无其他结果:线程安全的函数在调用过程中不应该产生影响其他线程执行的其他结果
可重入和线程安全的区别和联系分析
- 可重入性主要关注函数是否可以被中断后再次安全调用。它要求函数不使用静态或全局数据,并且所有数据通过参数或局部变量传递。
- 线程安全性主要关注在多线程环境下函数是否会引起数据竞争或其他并发问题。线程安全性通常通过互斥锁或其他同步机制来实现。
- 一个可重入的函数通常是线程安全的,但反之未必成立。
- 可重入性是一种更严格的要求,确保函数在任意中断和重入情况下都能正确工作。
死锁
概念
死锁指在多线程或者多进程环境下,两个或者多个线程进程相互等待对方持有的资源,从而导致它们都无法继续执行的状态。死锁就是一种系统僵局状态,所有参与的线程或者进程都在等待无法被满足的条件。
死锁产生的条件
互斥条件
- 资源不能被多个线程同时占用,即每个资源在一个时刻只能由一个线程占有。
请求和保持条件
- 一个线程已持有至少一个资源,并且还在请求其他资源,但这些资源被其他线程占有。
不剥夺条件
- 资源不能被强制剥夺,资源只能由持有它的线程主动释放。
环路等待条件
- 存在一个线程等待队列的闭环,其中每个线程都在等待下一个线程所持有的资源。
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx1;
std::mutex mtx2;
void thread1() {
std::lock_guard<std::mutex> lock1(mtx1);
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟一些工作
std::lock_guard<std::mutex> lock2(mtx2);
std::cout << "Thread 1 finished\n";
}
void thread2() {
std::lock_guard<std::mutex> lock1(mtx2);
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟一些工作
std::lock_guard<std::mutex> lock2(mtx1);
std::cout << "Thread 2 finished\n";
}
int main() {
std::thread t1(thread1);
std::thread t2(thread2);
t1.join();
t2.join();
return 0;
}
避免死锁
破坏互斥条件:
- 让资源变为可共享的(如读写锁),尽量减少资源的独占使用。
破坏请求和保持条件:
- 一次性请求所有需要的资源,避免在持有资源的同时再请求其他资源。
破坏不剥夺条件:
- 允许剥夺资源,当一个线程请求资源而得不到满足时,强制释放它已持有的资源。
破坏环路等待条件:
- 通过对资源进行排序,确保所有线程按同一顺序申请资源,避免循环等待。
线程同步
含义
Linux下的线程同步主要是协调多个线程对共享资源的访问,确保数据一致性和正确性的保护机制。线程同步的目的是防止数据竞争、死锁等问题的产生,确保程序在多线程环境下的正确执行。
线程同步即是多个线程之间的协调,让多线程按照一定的规则访问共享资源。
- 数据一致性:多个线程并发访问共享数据时,避免出现数据竞争和不一致的情况。
- 互斥访问:确保在任何时刻,只有一个线程可以访问共享资源。
- 顺序执行:确保线程按照一定的顺序执行某些操作,避免竞态条件。
线程同步的原因
数据竞争(Race Condition):
- 数据竞争发生在多个线程同时访问和修改共享资源,而这些访问没有正确同步时。数据竞争可能导致数据的不一致和不可预知的行为。
死锁(Deadlock):
- 死锁是指两个或多个线程相互等待对方释放资源,导致所有线程都无法继续执行的情况。
饥饿(Starvation):
- 饥饿是指某个线程长期得不到所需资源,导致无法继续执行的情况。这通常发生在资源分配不公平或优先级策略不当的情况下。
竞态条件(Race Condition):
- 竞态条件是指程序的输出依赖于多个线程执行的顺序,而这种顺序是不可控的。竞态条件会导致程序行为不一致和错误。
条件变量
条件变量使用场景分析。多线程模式下,线程想要访问临界资源,肯定会对临界资源不停的检测,但是不停的检测临界资源又在加锁和解锁之间急性;所以如果线程不停的去检查锁是否被释放,本身就会造成这把锁循环被该线程使用,导致其他执行流无法去访问临界资源。
类似于一个人不停的去询问咖啡是否做好了,导致咖啡师必须回答他的问题,无法继续加快速度做其他咖啡。
条件变量的产生就是为了解决该问题,避免其他线程频繁询问临界资源是否就绪,当条件就绪的时候通知该线程,然后该线程才来对临界资源进行访问。
条件变量就类似于不用再嘴动询问咖啡是否做好,只需要关注小程序是否通知你咖啡是否做好就可以。
条件变量是一种线程同步机制,用于线程之间协调复杂的条件。条件变量允许线程等待某个条件成立的时候进入睡眠状态,并在条件成立的时候将其唤醒。使用条件变量可以提高程序的效率和可读性,避免线程不停的询问造成不必要的性能开销。
提高效率:
- 通过使线程在等待条件时进入睡眠状态,条件变量避免了轮询带来的 CPU 开销。轮询(polling)是指线程不断检查条件是否满足,这会消耗大量 CPU 时间。
简化线程间的协调:
- 条件变量提供了一种简洁的方式来协调线程之间的操作。例如,生产者-消费者模型中,生产者在缓冲区满时等待,消费者在缓冲区空时等待,条件变量可以简化这种等待逻辑。
提高可读性和可维护性:
- 使用条件变量可以使代码更加清晰和易于理解。它明确表示线程在等待某个条件,而不是使用复杂的逻辑来实现相同的效果。
条件变量的使用
pthread_cond_init
参数:
cond
:指向需要初始化的条件变量。attr
:指向条件变量的属性对象。如果使用默认属性,可以传递NULL
。返回值:
- 成功时返回
0
。- 失败时返回错误码。
pthread_cond_wait
- 将线程放入条件变量的等待队列,并释放互斥锁。
- 当线程被唤醒时,重新获取互斥锁。
cond
:指向条件变量的指针。mutex
:指向互斥锁的指针。在等待条件变量时,互斥锁被自动释放,当条件满足时,线程重新获得该互斥锁。- 返回值
- 成功返回
0
。- 失败返回错误码。
函数中第二个参数是互斥量的原因
- 避免竞态条件,确保线程在等待条件变量和检查条件之间的操作是原子的
- 竞态条件:程序运行依赖于线程调度顺序的不确定性,从而导致不一致的结果
- 保护共享数据:使用互斥锁保护共享数据,确保对共享数据的访问是互斥的
- 避免竞态:
- 在等待条件变量满足时,如果不使用互斥锁,线程有可能在条件变量检查和等待之间被调度出去,从而导致竞态条件
- 使用互斥锁确保在调用pthread_cond_wait的时候,线程会原子释放互斥锁并进入等待状态,避免在此过程中发生竞态条件
- 唤醒时重新获得锁
- 当线程被唤醒的时候,即条件变量满足后,需要重新获取互斥锁,才能够继续执行。
使用过程分析
- 调用pthread_cond_wait
- 线程首先释放互斥锁(原子性)
- 线程进入条件变量的等待队列,等待被其他线程唤醒
- 唤醒后
- 首先重新获取互斥锁(原子性)
- 互斥锁重新获取后,线程继续执行,从而确保对共享数据的访问是安全的
竞态条件:程序的行为依赖于线程或者进程执行的顺序,这种顺序是不可控的,竞态条件通常发生在多个线程或者进程并发访问共享资源的时候。
pthread_cond_signal
- 从条件变量的等待队列中唤醒一个等待的线程
cond
:指向条件变量的指针。- 返回值
- 成功返回
0
。- 失败返回错误码。
pthread_cond_broadcast
- 唤醒等待队列中的所有线程。
cond
:指向条件变量的指针。- 成功返回
0
。- 失败返回错误码。
pthread_cond_destroy
cond
:指向要销毁的条件变量。- 成功返回
0
。- 失败返回错误码。
生产消费模型中条件变量的使用
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#define BUFFER_SIZE 10
int buffer[BUFFER_SIZE];
int count = 0; // 计数器,表示缓冲区中当前的项目数
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond_not_full;
pthread_cond_t cond_not_empty;
void* producer(void* arg) {
int item;
while (1) {
item = rand() % 100; // 生成一个随机数
pthread_mutex_lock(&mtx);
while (count == BUFFER_SIZE) { // 缓冲区满时等待
pthread_cond_wait(&cond_not_full, &mtx);
}
buffer[count] = item; // 将项目放入缓冲区
count++;
printf("Produced: %d, count: %d\n", item, count);
pthread_cond_signal(&cond_not_empty); // 通知消费者
pthread_mutex_unlock(&mtx);
sleep(1); // 模拟生产时间
}
}
void* consumer(void* arg) {
int item;
while (1) {
pthread_mutex_lock(&mtx);
while (count == 0) { // 缓冲区空时等待
pthread_cond_wait(&cond_not_empty, &mtx);
}
item = buffer[--count]; // 从缓冲区取出项目
printf("Consumed: %d, count: %d\n", item, count);
pthread_cond_signal(&cond_not_full); // 通知生产者
pthread_mutex_unlock(&mtx);
sleep(1); // 模拟消费时间
}
}
int main() {
pthread_t prod_thread, cons_thread;
// 初始化条件变量
pthread_cond_init(&cond_not_full, NULL);
pthread_cond_init(&cond_not_empty, NULL);
pthread_create(&prod_thread, NULL, producer, NULL);
pthread_create(&cons_thread, NULL, consumer, NULL);
pthread_join(prod_thread, NULL);
pthread_join(cons_thread, NULL);
// 销毁条件变量
pthread_cond_destroy(&cond_not_full);
pthread_cond_destroy(&cond_not_empty);
return 0;
}
条件变量在等待之前需要循环判断的原因
防止虚假唤醒
- 虚假唤醒是指线程在没有被条件变量显式唤醒的情况下,从
pthread_cond_wait
返回。这可能由于操作系统实现的细节或其他原因导致。为了确保条件变量的条件在被唤醒时依然有效,需要在循环中检查条件。确保条件成立
- 即使没有虚假唤醒,条件变量在多个线程竞争时,可能会导致唤醒的线程在再次检查条件时条件已经不再成立。例如,在生产者-消费者模型中,多个消费者可能同时被唤醒,但只有一个消费者可以消费一个项目,其他消费者需要再次检查条件并等待。
避免竞态条件
- 在多线程环境中,条件变量和互斥锁的组合可以防止竞态条件,但必须确保在条件变量等待前和唤醒后,条件变量的条件仍然成立。因此,需要在循环中反复检查条件。
信号量
信号量和条件变量类似,都是用于多线程或者多进程同步的一种机制,主要用于控制对资源的访问,防止资源竞争和死锁问题。
信号量主要有两种基本操作。等待,尝试减少信号量,让线程进入等待状态;信号,增加信号量的数值,并唤醒等待信号量的线程。
信号量的基本操作
- 等待(Wait):又称为P操作,尝试减小信号量的值。如果信号量的值大于0,则减小并继续执行;如果信号量的值等于0,则线程进入等待状态,直到信号量的值大于0。
- 信号(Signal):又称为V操作,增加信号量的值,并唤醒等待信号量的线程(如果有)。
POSIX信号量函数的使用
sem_init
- 参数
sem
:指向信号量对象的指针。pshared
:指定信号量是用于进程间共享还是用于线程间同步。0
表示用于线程间同步,非零值表示用于进程间共享。value
:信号量的初始值。- 返回值
- 成功返回
0
。- 失败返回
-1
并设置errno
。
sem_wait
sem
:指向信号量对象的指针。- 成功返回
0
。- 失败返回
-1
并设置errno
。
sem_post
sem
:指向信号量对象的指针。- 成功返回
0
。- 失败返回
-1
并设置errno
。
sem_destroy
sem
:指向信号量对象的指针。- 成功返回
0
。- 失败返回
-1
并设置errno
。
基于信号量的生产者和消费模型
#include <pthread.h>
#include <semaphore.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#define BUFFER_SIZE 10
int buffer[BUFFER_SIZE];
int count = 0; // 计数器,表示缓冲区中当前的项目数
sem_t empty; // 表示缓冲区中的空闲位置
sem_t full; // 表示缓冲区中的已占用位置
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
void* producer(void* arg) {
int item;
while (1) {
item = rand() % 100; // 生成一个随机数
sem_wait(&empty); // 等待空闲位置
pthread_mutex_lock(&mtx); // 加锁保护共享数据
buffer[count++] = item; // 将项目放入缓冲区
printf("Produced: %d, count: %d\n", item, count);
pthread_mutex_unlock(&mtx); // 解锁
sem_post(&full); // 增加已占用位置
sleep(1); // 模拟生产时间
}
}
void* consumer(void* arg) {
int item;
while (1) {
sem_wait(&full); // 等待已占用位置
pthread_mutex_lock(&mtx); // 加锁保护共享数据
item = buffer[--count]; // 从缓冲区取出项目
printf("Consumed: %d, count: %d\n", item, count);
pthread_mutex_unlock(&mtx); // 解锁
sem_post(&empty); // 增加空闲位置
sleep(1); // 模拟消费时间
}
}
int main() {
pthread_t prod_thread, cons_thread;
// 初始化信号量
sem_init(&empty, 0, BUFFER_SIZE); // 初始值为缓冲区大小
sem_init(&full, 0, 0); // 初始值为0,表示缓冲区开始为空
pthread_create(&prod_thread, NULL, producer, NULL);
pthread_create(&cons_thread, NULL, consumer, NULL);
pthread_join(prod_thread, NULL);
pthread_join(cons_thread, NULL);
// 销毁信号量
sem_destroy(&empty);
sem_destroy(&full);
pthread_mutex_destroy(&mtx);
return 0;
}
两个信号量控制缓冲区的空闲和已占用数量
empty
信号量:表示缓冲区中的空闲位置数量。生产者在生产新项目时,需要等待空闲位置的信号量,这样可以确保不会在缓冲区满时继续生产。full
信号量:表示缓冲区中的已占用位置数量。消费者在消费项目时,需要等待已占用位置的信号量,这样可以确保不会在缓冲区空时继续消费。
empty
初始化为BUFFER_SIZE
,表示缓冲区开始时是空的,有BUFFER_SIZE
个空闲位置。full
初始化为0
,表示缓冲区开始时没有任何项目
- 生产者线程
- 生产者线程就是生产随机数放入缓冲区;放入随机数之前需要等待empty信号量是否有空闲位置;放入随机数后需要通知消费者来消费
(只有条件满足的时候再往下执行)sem_wait(&empty)
:等待空闲位置的信号量。如果empty
大于0,则减小empty
的值并继续执行;如果empty
等于0,则阻塞直到empty
大于0。- 随机数放入缓冲区的同时,同步增加计数器count
sem_post(&full)
:增加已占用位置的信号量,通知消费者有新的项目可用。
- 消费者线程
- 消费者线程的主要任务是消费项目,并从缓冲区中取出项目。在取出项目之前,消费者需要等待已占用位置的信号量;在取出项目之后,消费者需要通知生产者有空闲位置。
sem_wait(&full)
:等待已占用位置的信号量。如果full
大于0,则减小full
的值并继续执行;如果full
等于0,则阻塞直到full
大于0。- 消费者每消费一个随机数,都会让count--
sem_post(&empty)
:增加空闲位置的信号量,通知生产者有空闲位置。
信号量每次等待或释放的时候会相应的增加或者减少
- 等待(sem_wait)
- 线程调用set_wait(&sem)的时候,如果信号量sem大于零,那么信号量自动减1,然后代码继续往后执行
- 如果信号量sem数值等于0,则线程会阻塞,直到信号量数值大于零并能够减1
- 释放(sem_post)
- 线程调用sem_post(&sem)时,信号量sem自动+1
- 如果有线程因为在
sem_wait(&sem)
中阻塞而等待信号量sem
,那么其中一个线程会被唤醒,继续执行。
生产者消费者模型
生产者消费模型是经典的多线程同步问题。生产者线程负责就爱那生成数据将其放入缓冲区,消费者负责从缓冲区中读取数据进行处理。该模型重点解决多线程环境下的数据共享以及同步。
具体实现参考条件变量和信号量相关代码。
线程池
线程池应用于程序管理以及线程复用,通过预先创建一组线程,减少当任务来临时再创建线程的性能开销。线程池通常维护一个任务队列,当有新任务提交到线程池后,线程池则将其任务分配给空闲的线程去执行。
线程池能够提高性能,因为其能够减少线程创建的开销,频繁复用已创建的线程,从而减少资源的消耗。线程池同时能提高系统的响应速度,线程池可以实现任务到达后立刻分配线程去处理,从而提高任务的处理速度。
线程池的主要工作流程
- 初始化线程池:创建一定数量的线程并将它们放入空闲线程队列。
- 任务提交:将任务添加到任务队列中。
- 任务分配:从任务队列中取出任务并分配给空闲线程执行。如果没有空闲线程,则任务在队列中等待。
- 任务执行:线程执行任务,当任务完成后,线程重新回到空闲线程队列中,等待下一个任务。