多线程编程中的重要概念
并行与并发
并发
在单核处理器环境下,操作系统通过时间片轮转的方式,让每个任务轮流占用 CPU 时间片,表面上多个任务同时在执行,实际上每个时刻只有一个任务在 CPU 上运行,任务之间交替执行,这种交替执行的现象被称为并发。比如,在单核电脑上同时运行音乐播放器和浏览器,音乐播放器播放音乐和浏览器加载网页的任务交替使用 CPU 时间片 ,给用户一种同时进行的错觉。
并行
并行是指在多个 CPU 核心上,多个任务真正意义上的同时执行。当系统具备多个 CPU 核心时,不同的任务可以被分配到不同的核心上,各自独立执行,互不干扰。例如,在多核服务器上,一个核心处理数据库查询任务,另一个核心处理用户请求任务,这些任务能够真正地同时进行。
在实际应用场景中,由于在并发场景下,单个核上的任务执行也具有类似并行的交替特性,所以常用 “并行” 一词来泛指并行和并发两种情况。
多线程优势:适用场景分析
多线程并非在所有场景下都能带来性能提升,其适用性取决于具体的应用场景类型,主要分为IO 密集型和CPU(计算)密集型。
多核环境
在多核处理器环境下,IO 密集型和 CPU 密集型任务都适合使用多线程技术。不过,IO 密集型任务更加适合多线程,原因在于:IO 密集型任务在执行过程中,大量时间处于等待外部 IO 设备(如磁盘读写、网络请求等)响应的状态,此时 CPU 处于空闲状态,多线程可以让 CPU 在等待 IO 操作完成的时间里去处理其他任务,充分利用 CPU 资源,提高系统整体效率。而对于 CPU 密集型任务,多线程可以将计算任务分配到多个核心上并行处理,加快任务完成速度。
单核环境
在单核处理器环境下,情况则有所不同。IO 密集型任务依然适合多线程,因为多线程可以在某个任务等待 IO 的过程中,切换到其他任务继续执行,避免 CPU 闲置。但 CPU 密集型任务并不适合多线程,这是因为线程调度存在额外开销,频繁的线程上下文切换会占用大量 CPU 时间。线程上下文切换是指保存当前线程的执行状态(如程序计数器、寄存器值等),然后加载另一个线程的执行状态,以便另一个线程能够继续执行,这一过程会消耗 CPU 资源,对于 CPU 密集型任务反而会降低执行效率。
IO 密集型与 CPU(计算)密集型任务
IO 密集型任务
IO 密集型任务主要涉及大量的 IO 操作,如文件读写、网络数据传输、数据库查询等。这类任务的特点是,CPU 处理时间较短,大部分时间都在等待外部 IO 设备完成操作。例如,在一个网络爬虫程序中,从网页下载数据时,需要等待网络响应,在等待过程中 CPU 处于空闲状态,此时利用多线程可以同时发起多个下载请求,充分利用网络带宽和 CPU 资源。
CPU(计算)密集型任务
CPU(计算)密集型任务侧重于大量的计算工作,如数据加密、数据分析等。这类任务几乎全程占用 CPU 资源,CPU 始终处于忙碌状态。例如,使用蒙特卡罗方法计算圆周率,整个过程主要依靠 CPU 进行大量的数值计算,几乎没有 IO 操作,此时(尤其单核环境下)过多的线程不仅不能提升效率,反而会因为线程上下文切换带来额外开销。
线程创建与数量限制
在实际开发中,为了完成任务,不能无限制地创建线程,线程并非越多越好。以网络库工作线程为例,通常按照 CPU 核心数量来确定线程数量,这是基于以下几个原因:
- 线程创建和销毁开销大:线程的创建和销毁涉及到用户空间向内核空间的切换。创建线程时,系统需要为其创建 PCB(进程控制块,在 Linux 中为 task_struct),分配线程内核栈,设置页目录 / 页表以及描述地址空间的数据结构(如 vm_struct、vm_area_struct 等),完成后再切换回用户空间 。销毁线程时同样需要进行复杂的清理工作。频繁地创建和销毁线程会消耗大量系统资源,因此在服务运行过程中,不宜实时创建和销毁线程。
- 线程栈占用内存多:每个线程都有自己独立的栈空间,用于存储局部变量、函数调用信息等。在大多数系统中,一个线程栈默认大小为 8MB。如果创建大量线程,这些线程栈会占用大量内存,可能导致系统内存不足。
- 线程上下文切换开销大:如前文所述,大量线程会导致频繁的上下文切换,这会占用大量 CPU 时间,降低 CPU 的利用率,使得任务执行效率下降。
- 系统负载过高:大量线程同时唤醒时,会使系统经常出现锯齿状负载或瞬间负载过大的情况,严重时可能导致系统宕机。
Fixed 模式和 Cached 模式线程池
为了更好地管理线程,避免上述问题,线程池技术应运而生。常见的线程池模式有 Fixed 模式和 Cached 模式:
Fixed 模式线程池
Fixed 模式线程池拥有固定数量的线程,线程池创建后,线程数量不会发生变化。这些线程会一直存在于线程池中,等待任务分配。当有任务提交时,空闲的线程会处理任务;如果所有线程都在忙碌,新任务会被放入任务队列等待。这种模式适用于任务数量可预测,且需要保证线程数量稳定的场景。例如,在一个订单处理系统中,订单处理任务的数量相对稳定,使用 Fixed 模式线程池可以合理分配线程资源,保证订单能够有序处理。
之前的文章中使用的就是Fixed 模式线程池RK3566/RK3588 YoloV5部署(四) yolov5多线程部署_yolov5如何用部署在rk3568上-CSDN博客
Cached 模式线程池
Cached 模式线程池在有任务提交时,如果当前线程池中有空闲线程,则复用空闲线程处理任务;如果没有空闲线程,则创建新的线程处理任务。当线程空闲时间超过一定阈值时,线程会被销毁。这种模式适用于任务数量波动较大,执行时间较短的场景。例如,在一个 Web 服务器中,处理用户的 HTTP 请求,请求数量不固定且处理时间较短,Cached 模式线程池可以灵活地根据任务数量调整线程数量,提高资源利用率。
线程同步与通信
竞态条件与临界区
一段代码能否在多线程环境下正确执行,关键在于这段代码是否存在竞态条件。竞态条件是指代码片段在多线程环境下执行时,由于线程的调度顺序不同,而得到不同的运算结果。存在竞态条件的代码段被称为临界区代码段,对于临界区代码段,需要保证其原子操作,以确保数据的一致性和正确性。如果一段代码不存在竞态条件,则称其为可重入代码;反之,则为不可重入代码。
线程互斥
在多线程环境下,当多个线程同时访问共享资源时,可能会出现数据不一致的问题,为了解决这个问题,需要使用线程互斥机制。常见的线程互斥手段有互斥锁 mutex 和原子变量 atomic。
互斥锁 mutex
互斥锁是一种最基本的同步工具,它保证同一时间只有一个线程能够访问被保护的共享资源。当一个线程获取到互斥锁后,其他线程如果想要访问该资源,必须等待锁的释放。例如:
#include <iostream>
#include <thread>
#include <mutex>std::mutex mtx;
int shared_variable = 0;void increment() {for (int i = 0; i < 10000; ++i) {std::lock_guard<std::mutex> guard(mtx);shared_variable++;}
}int main() {std::thread t1(increment);std::thread t2(increment);t1.join();t2.join();std::cout << "Final value: " << shared_variable << std::endl;return 0;
}
在上述代码中,通过 std::lock_guardstd::mutex guard (mtx) 语句获取互斥锁,保证了对 shared_variable 的操作是原子性的,避免了数据竞争。
原子变量 atomic
原子变量是一种特殊类型的变量,其操作是原子的,即不会被线程调度打断。例如,std::atomic类型的变量,对其进行的加减等操作都是原子操作,不需要额外的锁保护。
#include <iostream>
#include <thread>
#include <atomic>std::atomic<int> shared_atomic_variable(0);void atomic_increment() {for (int i = 0; i < 10000; ++i) {shared_atomic_variable++;}
}int main() {std::thread t1(atomic_increment);std::thread t2(atomic_increment);t1.join();t2.join();std::cout << "Final atomic value: " << shared_atomic_variable << std::endl;return 0;
}
线程通信:生产者消费者模型
线程通信是多线程编程中的重要环节,生产者消费者模型是一种经典的线程通信方式。在生产者消费者模型中,生产者线程负责生成数据并放入缓冲区,消费者线程从缓冲区中取出数据进行处理。为了保证生产者和消费者之间的协调工作,需要使用条件变量等同步机制。
条件变量可以让线程在满足特定条件之前进入等待状态,当条件满足时,再唤醒等待的线程。在使用条件变量时需要注意,它会改变当前线程状态为等待,然后释放锁(因此不能使用 lock_guard,因为其释放锁只能在析构函数释放)。以下是一个简单的生产者消费者模型示例:
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>std::mutex mtx;
std::condition_variable cv;
std::queue<int> buffer;
const int buffer_size = 5;// 生产者线程
void producer() {int data = 0;while (true) {{std::unique_lock<std::mutex> lock(mtx);cv.wait(lock, [] { return buffer.size() < buffer_size; });buffer.push(data++);std::cout << "Produced: " << buffer.back() << std::endl;}cv.notify_one();}
}// 消费者线程
void consumer() {while (true) {{std::unique_lock<std::mutex> lock(mtx);cv.wait(lock, [] { return!buffer.empty(); });int data = buffer.front();buffer.pop();std::cout << "Consumed: " << data << std::endl;}cv.notify_one();}
}int main() {std::thread t1(producer);std::thread t2(consumer);t1.join();t2.join();return 0;
}
在上述代码中,通过条件变量 cv 和互斥锁 mtx,实现了生产者和消费者之间的同步。
通过对并行与并发、多线程技术的深入探讨,我们对多线程编程有了更全面的认识。在实际开发中,合理运用这些知识,能够编写出高效、稳定的多线程程序。