进程/线程/协程
进程:
- 进程是操作系统进行资源分配的基本单位,每个进程都有自己独立内存空间。
- 由于每个进程都有独⽴的内存空间,创建和销毁进程的开销较⼤。
- 进程间切换需要保存和恢复整个进程的状态(栈、寄存器虚拟内存、文件句柄、打开的文件资源信息等),因此上下⽂切换的开销较高。
- 由于进程间相互隔离,进程之间的通信需要使⽤⼀些特殊机制,如管道、消息队列、共享内存等。
- 由于进程间相互隔离,⼀个进程的崩溃不会直接影响其他进程的稳定性。
线程:
- 线程是操作系统进⾏运算调度的最⼩单位,线程是进程的⼦任务,是进程内的执⾏单元,是进程的一个实体。
- ⼀个进程至少有⼀个线程,⼀个进程可以运⾏多个线程,这些线程共享同⼀块内存。
- 线程只拥有一点在运行中必不可少的资源(如:程序计数器、一组寄存器和栈),但是它可以和同属一个进程的其他线程共享该进程所获得的全部资源,创建和销毁线程的开销较小。
- 线程间切换只需要保存和恢复少量的线程上下⽂,因此上下⽂切换的开销较小。
- 由于线程共享相同的内存空间,它们之间可以直接访问共享数据,线程间通信更加⽅便。
- 由于线程共享相同的内存空间,⼀个线程的错误可能会影响整个进程的稳定性。
协程
协程是一种用户态的轻量级线程,协程的调度由应用程序控制(也就是在用户态执行)。
协程拥有自己的寄存器、上下文和栈。协程调度切换时,如果是无栈的情况下将寄存器上下文和栈保存到线程的堆区,在切回来的时候,恢复先前保存的寄存器的上下文和栈。由于直接操作栈,基本没有内核切换的开销,可以不加锁地访问全局变量,因此上下文的切换非常快。
协程虽然被称为用户态线程,但单线程下的多协程本质上还是串行执行的,只能是一个协程结束或yield后,再执行另一个协程。只能用到单核计算资源,而线程是可以真正并发执行的,所以线程才是系统调度的基本单位,因此协程往往要与多线程、多进程一起使用。
为什么虚拟地址空间切换会比较耗时?
进程都有自己的虚拟地址空间,把虚拟地址转换为物理地址需要查找页表,页表查找是一个很慢的过程,因此通常使用 Cache 来缓存常用的地址映射,这样可以加速页表查找,这个 Cache 就是 TLB(translation Lookaside Buffer, TLB本质上就是一个 Cache,是用来加速页表查找的)。
由于每个进程都有自己的虚拟地址空间,那么显然每个进程都有自己的页表,那么当进程切换后页表也要进行切换,页表切换后 TLB就失效了,Cache 失效导致命中率降低,那么虚拟地址转换为物理地址就会变慢,表现出来的就是程序运行会变慢,而线程切换则不会导致 TLB失效,因为线程无需切换地址空间,这也就是为什么线程切换要比较进程切换块。
如何设计⼀个支持最大并发数的线程池?比如最大并发数为3
1.任务管理
需要一个任务队列来存储待执行的任务,并在有空闲线程时取出任务执行
- 使用任务队列
- 采用线程安全的队列(如
std::queue<std::function<void()>>
+std::mutex
) - 任务提交时先进入队列,只有当线程可用时才会取出任务执行
- 采用线程安全的队列(如
- 任务调度策略
- FIFO(先进先出):先提交的任务先执行
- 优先级调度(可选):使用
std::priority_queue
让重要任务优先执行 - 拒绝策略(当任务队列满时):
- 阻塞等待(默认策略)
- 丢弃最老任务(如
std::deque
维护,pop_front 丢弃旧任务) - 抛出异常
2.线程管理
- 固定线程数:初始化 3 个工作线程,保持最大并发数
3
- 避免频繁创建/销毁线程,提高性能
- 每个线程循环从任务队列取任务,执行后继续取任务
3.同步机制
std::mutex
+ std::condition_variable
如果任务队列的任务之间有依赖关系要怎么处理呢?
采用任务依赖图(DAG)+ 条件变量 + 任务调度机制来管理任务
- 每个任务维护一个前置任务列表
- 只有当所有前置任务完成后,任务才能执行
- 使用
std::unordered_map<int, std::vector<int>>
记录依赖关系 - 使用
std::atomic<int>
计数器跟踪任务完成状态 - 任务完成后,通知所有等待的任务