计算机操作系统之进程、线程、线程池、协程
1、进程、线程、协程
用一个表格来总结
进程 | 线程 | 协程 | |
---|---|---|---|
定义 | 进程就是进程实体,进程是资源分配和调度的基本单位。进程是程序的一次执行过程。进程是一个程序及其数据在处理机上顺序执行时所发生的活动。 | 线程是程序执行的基本单位。线程是轻量级的进程。每个进程都有唯一的主线程,主线程和线程是相互依存的关系,主线程结束进程也会结束 | 协程是线程内部调度的基本单位 |
切换者 | 操作系统 | 操作系统 | 用户 |
切换过程 | 用户态->内核态->用户态 | 用户态->内核态->用户态 | 用户态 |
拥有资源 | CPU资源、内存资源、文件资源、句柄 | 程序计数器、寄存器、栈、状态字 | 拥有自己的寄存器上下文和栈 |
并发性 | 不同进程之间切换实现并发,各自占有CPU实现并发 | 一个进程内部的多个线程并发执行(和CPU核数相等) | 同一时间只能执行一个协程,而其他协程处于休眠状态,适合对任务进行分时处理 |
系统开销 | 开销很大 | 开销小 | 直接操作栈,基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文切换非常快 |
通信 | 借助操作系统,Linux下进程间通信方式:管道、共享内存、消息队列、套接字、信号、信号量 | 线程间可以直接读写进程数据段(如全局变量)来进行通信,Linux下线程间通信方式:信号、锁机制、条件变量、信号量 | 共享内存,消息队列 |
2、进程和线程的比较
- 进程有自己的独立地址空间,线程没有
- 进程和线程的通信方式不同
- 进程切换上下文开销大,线程的系统开销小
- 一个进程挂掉不会影响其他进程,而线程挂掉会影响其他线程
- 线程之间有先后访问顺序(线程依赖关系)
- 线程启动速度快,轻量级
- 线程使用有一定难度,需要处理数据一致性问题
- 同一线程共享堆、全局变量、静态变量、指针、引用、文件等,而独自占有栈
- 调度:线程是调度的基本单位,进程是资源调度的基本单位
- 并发性:一个进程内多个线程可以并发(最好和CPU核数相等),多个进程可以并发
- 拥有资源:线程不拥有系统资源,但一个进程的多个线程可以共享隶属进程的资源,进程是拥有资源的独立单位
- 系统开销:线程创建销毁只需要处理PC值、状态码、通用寄存器值、线程栈、栈指针;进程的创建和销毁需要重新分配及销毁task_struct结构
3、什么时候用进程、什么时候用线程?
- 频繁修改:需要频繁创建和销毁的优先使用线程,创建和销毁一个进程的代价很大
- 计算量:需要大量计算的优先使用线程,因为需要消耗大量CPU资源且切换频繁,所以多线程好一点
- 相关性:任务间相关性比较强的用线程,相关性比较弱的用进程。因为线程之间的数据共享和同步比较简单
- 多分布:可能要扩展到多机分布的用进程,多核分布的用线程
- 稳定性:需要更加稳定安全的时候,选择进程,需要速度更快,选择线程
4、进程之间的通信方式(Linux下)
-
管道
- 无名管道(内存文件):管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系(父子进程关系)的进程间使用。
- 有名管道(FIFO文件,借助文件系统):有名管道也是半双工的通信方式,允许在没有亲缘关系的进程间使用,管道是先进先出的通信方式
- 管道只能承载无格式字节流
-
共享内存
- 共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问
- 共享内存往往与信号量配合使用来实现进程间的同步和通信
-
消息队列
- 消息队列是有消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流,以及缓冲区大小受限等缺点
-
套接字
- 适用于不同机器间的进程通信,在本地也可以作为两个进程通信方式
-
信号
- 用于通知接收进程某个事件已经发生,比如按下ctrl+c就是信号
- 信号传递的信息少
-
信号量
- 信号量是一个计数器,用来控制多个进程对共享资源的访问
- 信号量常作为一种锁机制,实现进程、线程对临界区的同步及互斥访问
5、线程之间的通信方式(Linux下)
- 信号:类似进程间的信号处理
- 锁机制:互斥锁、读写锁、自旋锁
- 条件变量:使用通知的方式解锁,与互斥锁配合使用
- 信号量:包括无名线程信号量和命名线程信号量
6、进程同步的四种方式
-
临界区
-
对临界资源进行访问的那段代码称为临界区
-
为了互斥访问临界资源,每个进程在进入临界区之前需要先进行检查
-
临界区速度最快,但只能作用于同一进程下不同线程,不能作用于不同进程;临界区可确保某一代码段同一时刻只被一个线程执行;
-
-
同步和互斥
- 同步:多个进程因为合作产生的直接制约关系,使得进程有一定的先后执行关系
- 互斥:多个进程在同一时刻只有一个进程能进入临界区
-
信号量
- 使用信号量实现生产者消费者问题
-
管理
- 使用信号量实现生产者消费者问题
7、一个进程可以创建多少个线程
- 理论上,一个进程可用的虚拟空间是2G,默认情况下,线程栈的大小是1MB,所以理论上最多只能创建2048个线程
- 一个进程可以创建的线程数由可用虚拟空间和线程的栈的大小共同决定
8、进程间的状态切换
-
进程的三种基本状态
- 运行态(running):占有CPU,并在CPU上运行。单核处理机环境下,每一时刻只有一个进程处于运行状态(双核环境下则是两个进程)
- 就绪态(ready):已经具备运行条件,但由于没有空闲CPU,暂时不能运行。万事俱备,只欠CPU
- 阻塞态(waiting/blocked):因等待某一事件暂时不能运行。等待操作系统分配打印机、等待读磁盘操作的结果。CPU是计算机中最昂贵的部件,为了提高CPU利用率,需要将他们资源分配到位,才能得到CPU的服务。
- 另外两种状态:
- 创建态(new):新建态
- 终止态(terminated):结束态
-
进程状态的转换
- 只有就绪态和运行态可以相互转换,其他都是单向转换
9、几种典型的锁
- 读写锁
- 多个读者可以同时读
- 写者必须互斥(只允许一个写者写,也不能读者写者同时进行)
- 写者优于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)
- 互斥锁
- 一次只能一个线程拥有互斥锁,其他线程只有等待
- 互斥锁是在抢锁失败的情况下主动放弃CPU进入睡眠状态直到锁的状态改变时再唤醒,而操作系统负责线程调度,为了实现锁的状态发生改变时唤醒阻塞的线程或者进程,需要把锁交给操作系统管理
- 互斥锁属于sleep-waiting类型的锁
- 条件变量
- 互斥锁的一个明显缺点是它只有两种状态:锁定和非锁定。
- 条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足,和互斥锁一起使用,避免出现竞态条件。互斥锁是线程间的互斥机制,条件变量则是同步机制
- 自旋锁
- 如果进程线程无法取得锁,进程线程不会立刻放弃CPU时间片,而是一直循环尝试获取锁,直到获取为止。如果别的线程长时间占有锁,那么自旋锁就是在浪费CPU做无用功,但是自旋锁一般应用于加锁时间很短的场景,这个时候效率比较高
- 自旋锁属于busy-waiting类型的锁,自旋锁不会引起调用者睡眠,所以自旋锁的效率远高于互斥锁
- 在自旋锁时有可能造成死锁,当递归调用的时候可能造成死锁
10、系统并发与并行
- 并发指的是宏观上一段时间内能同时运行多个程序,微观上是交替发生的
- 并行指的是同一时刻能运行多个指令,两个或者多个事件在同一时刻发生
- 操作系统引入了进程和线程,使得程序能够并发运行
11、共享是什么
共享是指系统中的资源可以被第一个并发进程同时使用
有两种共享方式:互斥共享和同时共享
- 互斥共享:互斥共享的资源称为临界资源,例如打印机、qq微信同时视频等,在同一时刻只允许一个进程访问,需要用同步机制来实现互斥访问
- 同时共享:一个时间段内由多个进程同时(是指宏观上)对它们进行访问(QQ和微信同时发送文件)
12、线程池
线程池是处理线程多并发,用一个数组保存线程,然后一直放着,如果没用,就用条件变量让它休眠,如果加入一个新的任务就唤醒其中一个去执行这个任务
**采用线程池时:**创建线程 -> 由该线程执行任务 -> 任务执行完毕后销毁线程。即使需要使用到大量线程,每个线程都要按照这个流程来创建、执行与销毁。
虽然创建与销毁线程消耗的时间远小于线程执行的时间,但是对于需要频繁创建大量线程的任务,创建与销毁线程 所占用的时间与CPU资源也会有很大占比。
-
为了减少创建与销毁线程所带来的时间消耗与资源消耗,因此采用线程池的策略:
程序启动后,预先创建一定数量的线程放入空闲队列中,这些线程都是处于阻塞状态,基本不消耗CPU,只占用较小的内存空间。接收到任务后,任务被挂在任务队列,线程池选择一个空闲线程来执行此任务。任务执行完毕后,不销毁线程,线程继续保持在池中等待下一次的任务。
-
线程池所解决的问题:
需要频繁创建与销毁大量线程的情况下,由于线程预先就创建好了,接到任务就能马上从线程池中调用线程来处理任务,减少了创建与销毁线程带来的时间开销和CPU资源占用。
需要并发的任务很多时候,无法为每个任务指定一个线程(线程不够分),使用线程池可以将提交的任务挂在任务队列上,等到池中有空闲线程时就可以为该任务指定线程。
-
线程池代码
#include <condition_variable> #include <functional> #include <future> #include <memory> #include <mutex> #include <queue> #include <stdexcept> #include <thread> #include <utility> #include <vector> #include <iostream> class ThreadPool { public: explicit ThreadPool(size_t thread_size); template <class F, class... Args> auto enqueue(F&& f, Args&&... args) -> std::future<typename std::result_of<F(Args...)>::type>; ~ThreadPool(); private: // need to keep track of threads so we can join them std::vector<std::thread> workers_; // the task queue std::queue<std::function<void()> > tasks_; // 同步 std::mutex queue_mutex_; std::condition_variable condition_; bool stop_; }; // 构造函数,启动一些线程 inline ThreadPool::ThreadPool(size_t thread_size) : stop_(false) { auto thread_handler = [this] { for (;;) { std::function<void()> task; { std::unique_lock<std::mutex> lock(queue_mutex_); condition_.wait(lock, [this] { return stop_ || !tasks_.empty(); }); if (stop_ && tasks_.empty()) return; task = std::move(tasks_.front()); tasks_.pop(); } task(); } }; for (size_t i = 0; i < thread_size; ++i) { workers_.emplace_back(thread_handler); } } // 将新的任务添加到线程池中 template <class F, class... Args> auto ThreadPool::enqueue(F&& f, Args&&... args) -> std::future<typename std::result_of<F(Args...)>::type> { using return_type = typename std::result_of<F(Args...)>::type; auto task = std::make_shared<std::packaged_task<return_type()> >( std::bind(std::forward<F>(f), std::forward<Args>(args)...)); std::future<return_type> res = task->get_future(); { std::unique_lock<std::mutex> lock(queue_mutex_); // don't allow enqueueing after stopping the pool if (stop_) throw std::runtime_error("enqueue on stopped ThreadPool"); tasks_.emplace([task]() { (*task)(); }); } condition_.notify_one(); return res; } // 析构函数,join所有线程 inline ThreadPool::~ThreadPool() { { std::unique_lock<std::mutex> lock(queue_mutex_); stop_ = true; } condition_.notify_all(); for (std::thread& worker : workers_) { worker.join(); } } void fun1(int a) { std::cout << "a: " << std::this_thread::get_id() << " " << a << std::endl; } int main() { ThreadPool thread_pool_(2);//构造一个含有两个线程的线程池 int a = 1; while (1) { //利用线程池处理fun1 thread_pool_.enqueue(fun1, a); } return 0; }
获取线程ID,可以看到线程ID为44和48的两个线程交替输出a=1,两个子线程。
-
单线程代码
#include <thread> #include <iostream> void fun1(int a) { std::cout << "thread ID: " << std::this_thread::get_id() << " output a = " << a << std::endl; } int main() { int a = 5; while (1) { std::thread th(fun1, 5); th.join(); } return 0; }
单线程输出情况,只有一个ID
13、进程调度算法
-
先来先服务
- 非抢占式的调度算法,按照请求的顺序进行调度
- 有利于长作业,不利于短作业,因为短作业必须等待前面的长作业执行完毕才能执行,而长作业又要执行很长时间,造成短作业等待时间过长
-
短作业优先(short job first)
- 非抢占式调度算法,按照运行时间最短的顺序进行调度
- 长作业可能会饿死,处于一直等待短作业执行完毕的状态,如果一直有短作业到来,那么长作业一直得不到调度
-
最短剩余时间优先(shortest remaining time next)
- 最短作业优先的抢占版本,按剩余运行时间的顺序进行调度,当一个新的作业到达时,其整个运行时间与当前进程剩余时间作比较
- 如果新的进程需要的时间更少,则挂起当前进程,运行新的进程,否则新的进程等待
-
时间片轮转
-
将所有就绪进程按 FCFS 的原则排成一个队列,每次调度时,把 CPU 时间分配给队首进程,该进程可以
执行一个时间片。 -
当时间片用完时,由计时器发出时钟中断,调度程序便停止该进程的执行,并将它送往就绪队列的末尾,同时继续把 CPU 时间分配给队首的进程。
-
时间片轮转算法的效率和时间片的大小有很大关系:因为进程切换都要保存进程的信息并且载入新进程的信息,如果时间片太小,会导致进程切换得太频繁,在进程切换上就会花过多时间。而如果时间片过长,那么实时性就不能得到保证。
-
-
优先级调度
- 为每个进程分配一个优先级,按优先级进行调度。
- 为了防止低优先级的进程永远等不到调度,可以随着时间的推移增加等待进程的优先级。
-
多级反馈队列
-
一个进程需要执行 100 个时间片,如果采用时间片轮转调度算法,那么需要交换 100 次。
-
多级队列是为这种需要连续执行多个时间片的进程考虑,它设置了多个队列,每个队列时间片大小都不同,例如 1,2,4,8,…。进程在第一个队列没执行完,就会被移到下一个队列。这种方式下,之前的进程只需要交换 7 次。每个队列优先权也不同,最上面的优先权最高。因此只有上一个队列没有进程在排队,才能调度当前队列上的进程。
-
可以将这种调度算法看成是时间片轮转调度算法和优先级调度算法的结合。
-
14、死锁相关问题总结
死锁是指两个或者多个线程相互等待对方数据的过程,死锁的产生会导致程序卡死,不解锁程序将永远无法进行下去
-
死锁产生的原因
四个必要条件
- 互斥条件:进程对需求的资源具有排他性,若有其他进程请求该资源,请求进程只能等待
- 不剥离条件:进程在所获得的资源未释放前,不能被其他进程强行夺走,只能自己释放
- 请求和保持条件:进程当前所拥有的资源在进程请求其他新资源,由该进程继续占有
- 循环等待条件:存在一种进程资源循环等待关系,每个进程已获得资源的同时被下一个进程请求,A等B,B等C,C等A,死循环
-
死锁代码举例
#include <iostream> #include <list> #include <mutex> //引入互斥量头文件 #include <thread> #include <vector> using namespace std; class A { public: //插入消息,模拟消息不断产生 void insertMsg() { for (int i = 0; i < 100; i++) { cout << "插入一条消息:" << i << endl; my_mutex1.lock(); //语句1 my_mutex2.lock(); //语句2 Msg.push_back(i); my_mutex2.unlock(); my_mutex1.unlock(); } } //读取消息 void readMsg() { int MsgCom = 0; for (int i = 0; i < 100; i++) { MsgCom = MsgLULProc(); if (MsgCom) { //读出消息了 cout << "消息已读出" << MsgCom << endl; } else { //消息暂时为空 cout << "消息为空" << endl; } } } //加解锁代码 int MsgLULProc() { int curMsg; my_mutex2.lock(); //语句3 my_mutex1.lock(); //语句4 if (!Msg.empty()) { //读取消息,读完删除 curMsg = Msg.front(); Msg.pop_front(); my_mutex1.unlock(); my_mutex2.unlock(); return curMsg; } my_mutex1.unlock(); my_mutex2.unlock(); return 0; } private: std::list<int> Msg; //消息变量 std::mutex my_mutex1; //互斥量对象1 std::mutex my_mutex2; //互斥量对象2 }; int main() { A a; //创建一个插入消息线程 std::thread insertTd(&A::insertMsg, &a); //这里要传入引用保证是同一个对象 //创建一个读取消息线程 std::thread readTd(&A::readMsg, &a); //这里要传入引用保证是同一个对象 insertTd.join(); readTd.join(); return 0; }
语句1和语句2表示线程A先锁资源1,再锁资源2
语句3和语句4表示线程B先锁资源2,再锁资源1
具备产生死锁的条件
-
死锁解决方案
- 保证上锁的顺序一致
-
死锁处理方法
- 鸵鸟策略
- 不采取任何措施
- 当发生死锁时不会对用户造成多大影响,或发生死锁的概率很低,就可以采用鸵鸟策略
- 死锁检测和死锁恢复
- 不试图阻止死锁,而是当检测到死锁发生时,采取措施进行恢复
- 死锁预防
- 在程序运行之前预防死锁发生
- 破坏死锁的四个必要条件
- 死锁避免
- 在程序运行时避免发生死锁
- 安全状态
- 单个资源的银行家算法
- 多个资源的银行家算法
- 鸵鸟策略