进程
1.概念
进程是操作系统进行资源分配和调度的一个独立单位,是应用程序运行的载体
进程由程序、数据集合、进程控制块PCB组成
2.PCB
PCB是OS为了系统的描述和管理进程的运行,在内核中为每个进程专门定义的一个数据结构。
PCB是OS感知和调度进程的唯一handle
存储的信息包括:
- 进程标识符(外部标识符方便创建者识别、内部标识符pid方便OS识别)
- CPU上下文(寄存器内容、用户栈指针),方便切换和恢复
- 进程调度信息(状态、优先级、已执行时间、阻塞原因即事件所等待事件,用于调配CPU资源)
- 进程控制信息(程序和数据的首地址、进程同步和通信机制、资源清单用于避免死锁)
3.进程状态
- 创建状态:进程正在被创建
- 就绪状态:进程被加入到就绪队列中等待CPU调度运行
- 执行状态:进程正在被运行
- 等待阻塞状态:进程因为某种原因,比如等待I/O,等待设备,而暂时不能运行
- 终止状态:进程运行完毕
4.挂起和激活
挂起(Suspend):进程释放内存调至到外存,处于静止状态,无法被调度
激活(Active):进程从外存中重新导入到内存
挂起原因:
- 终端用户的请求
- 父进程的请求
- 负荷调节的需要,工作负荷较重,系统把不重要的进程挂起
新状态:
- 被挂起状态下,事件发生,阻塞状态----->就绪状态
- 创建态被挂起---->静止就绪
- 执行态被挂起----->静止就绪
- 静止阻塞–no–>活动就绪
- 就绪状态可以被挂起(静止就绪)、或激活(活动就绪)
- 就绪状态可以被挂起(静止阻塞)、或激活(活动阻塞)
进程间通信
进程间通信:管道、系统IPC(消息队列、信号量、信号、共享内存等)、套接字socket
在内核中开辟一块缓冲区,进程A把数据从用户空间拷到内核缓冲区,进程B再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信。
1.匿名管道PIPE
#include <unistd.h>
int pipe (int fd[2]);
fd参数返回两个文件描述符,fd[0]指向管道的读端,fd[1]指向管道的写端。
本质:
- 内核中开辟的一块内存缓冲区
读写异常:
- 写段一直写,读端引用计数大于0但不读,管道积满,write将堵塞
- 读段一直读,写端引用计数大于但不写,管道变空,read将堵塞
- 读端一直读,写端写了一部分后关闭,read返回0像读到EOF
- 写端一直写,读端读了一部分后关闭,写端进程将收到SIGPIPE信号,导致进程异常终止
特点:
- 匿名管道只允许亲缘(父子、兄弟)关系进程间通信
- 半双工通信
- 管道保证同步机制,访问数据一致性可保证
- 面向字节流
- 管道依靠进程存在,进程消失对应端口关闭,两个进程消失管道也消失
- 管道大小64KB
2.命名管道FIFO文件
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *filename, mode_t mode);
open(const char *pathf, O_RDONLY);//1
open(const char *path, O_RDONLY | O_NONBLOCK);//
open(const char *path, O_WRONLY);//3
open(const char *path, O_WRONLY | O_NONBLOCK);//
read(pipe_fd, buffer, PIPE_BUF);
write(pipe_fd, buffer, size);
本质:
- 特殊类型的文件,存在于文件系统,但只是一个名字,本质仍然是内核的一块缓冲区
读写特性
- 不能以O_RDWR模式打开,FIFO为了单向传递数据
- 阻塞式open调用,如果方式是只读open,除非另一个进程以只写open打开同一FIFO,否则open阻塞。读写反过来亦然
- FIFO大小64KB
读写安全
- 阻塞式只读open的写操作是原子化的,写入数据长度<=PIPE_BUF的时候,要么全部写入要么一个字节都不写入。保证多个进程的写请求的数据不会交错
- 大于PIPE_BUF不保证原子性
FIFO的优点
- 可在无关进程间交换数据
管道缺点
- 不保证数据持久性,当一个PIPE或FIFO的最后一次关闭发生时,仍在该PIPE或FIFO上的数据将被丢弃
- 只能承载无格式字节流
- 缓冲区大小受限,传递信息小
管道并发
使用多个线程,开启多个管道进行全双工通信或者并发
3.消息队列
消息队列,存放在内核内存中的链接表,由队列ID标记
int msgget(key_t, key, int msgflg);
int msgsend(int msgid, const void *msg_ptr, size_t msg_sz, int msgflg);
int msgrcv(int msgid, void *msg_ptr, size_t msg_st, long int msgtype, int msgflg);
int msgctl(int msgid, int command, struct msgid_ds *buf);
特点:
- 消息队列提供了不同进程可以通信的队列ID
- 发送接收需要按照固定的数据结构,接收和发送的大小不包括msg_type
- 发送方需要设置msg_type
- 接收方指定msg_type接收数据,0代表接收所有类型的第一个数据,大于0则只接受队列中对应类型的第一个数据(不符合则不接受),小于零按照最小绝对值匹配对应类型数据
优点:
- 消息队列可以独立于发送接收进程存在,不受制于FIFO的打开同步
- 接收消息可以按消息类型接收,而不是默认接收第一个
4.信号量semaphore
信号量是一个计数器,可以用来控制多个进程对共享资源的访问。信号量用于实现进程间的互斥与同步,而不是通信
核心:
- P(sv):如果sv的值大于零,就给它减1;如果它的值为零,就挂起该进程的执行
- V(sv):如果有其他进程因等待sv而被挂起,就让它恢复运行,如果没有进程因等待sv而挂起,就给它加1.
例子:
- 假设是二进制信号量,只有0和1,初始为1
- 进程A成功执行P(s)操作,信号量减1等于0,进程A进入临界区
- 进程B试图执行P(s)操作,信号量为0,进程B被挂起
- 进程A退出临界区,执行V(s)操作,进程B即可恢复运行,信号量还是为0
- 进程B退出临界区,执行V(s)操作,信号量加1等于1
特点:
- 信号量用于进程间同步,若要在进程间传递数据需要结合共享内存
- 信号量基于操作系统的 PV 操作,程序对信号量的操作都是原子操作
- 每次对信号量的 PV 操作不仅限于对信号量值加 1 或减 1,而且可以加减任意正整数
注意:
- 进程需要负责散出信号量,否则进程退出后仍然存在,信号量是有限的资源
5.共享内存
多个进程可以访问同一块内存空间,这种方式需要依靠某种同步操作,互斥锁、信号量。
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);//开辟或者得到共享内存
void *shmat(int shm_id, const void *shm_addr, int shmflg);//链接共享内存到当前进程地址空间
int shmdt(const void *shmaddr);//从当前进程分离
int shmctl(int shm_id, int command, struct shmid_ds *buf);//控制(删除共享内存)
特点:
- 共享内存是最快的一种IPC,因为数据不需要来回复制,直接对内存进行存取
- 信号量+共享内存通常结合在一起使用
?共享内存原理
共享内存是通过把同一块物理内存分别映射到不同的进程空间中实现进程间通信
6.信号signal
信号是UNIX和Linux系统响应某些条件而产生的一个事件,接收到信号的进程会相应地采取一些行动。通常信号由一个错误产生,也可作为进程间通信用信号触发自定义的行为。
一个进程以pid发送信号给另一个进程,接收到的进程做出相应行为。
#include <signal.h>
void (*signal(int sig, void (*func)(int)))(int);//信号处理函数
int sigaction(int sig, const struct sigaction *act, struct sigaction *oact);//信号处理函数
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);//信号发送函数
#include <unistd.h>
unsigned int alarm(unsigned int seconds);//信号发送函数,只能发送给自己,作为一个闹钟函数
信号集函数用于编辑一个集合,该集合的信号用作将被屏蔽
信号竞态:
- 信号处理函数建立之前就接收到要处理的信号,进程将无法按照预期处理
- 使用sa_mask将信号加入到进程的信号屏蔽字中,防止以上情况
- 在调用sa_handler所指向的信号处理函数之前,该信号集将被加入到进程的信号屏蔽字中
等待信号:
- 在等待信号期间没有事情可做,可使用pause函数挂起进程
- 当接收到信号时,信号处理函数将运行,程序恢复正常执行
- 节省CPU资源
安全问题:
- 当进程接收到一个信号,执行信号处理函数期间又收到一个信号,信号处理函数被中断
- 这要求信号重入函数是可重入的,被中断后可以再次安全地进入和执行
- 如果进程接收异常信号,但没有安排捕获,进程将会终止
SIGCHLD信号代表子进程已经停止或退出
信号的处理
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-180Hs7yg-1587028203574)(C:\Users\cqlia\AppData\Roaming\Typora\typora-user-images\1584328117158.png)]
-
信号递达(Delivery),实际执行信号的处理动作
-
信号未决(Pending),信号从产生到递达之间的状态
-
阻塞(Block),被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作
- SIGKILL 和 SIGSTOP 不能被阻塞
-
忽略(Ignore),递达之后可选的一种处理动作
-
信号处理函数只能解除信号pending,无法接触信号Block
7.套接字socket
socket也是一种进程间通信机制,可以在不同主机之间进行进程通信
进程创建
1.根进程
- pid是操作系统识别进程的唯一标识符,
- pid用作索引访问内核中的进程的各种属性。
- 进程 init(pid = 1),是所有用户进程的根进程或父进程。
- 操作系统支持的pid即进程最大数目为short int最大值
2.系统调用fork()
fork()系统调用由clone()系统调用实现,复制现有进程的task_struct进程描述符(PCB),并添加到双向循环任务链表中。
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝至子进程
- 添加子进程到系统进程列表当中
- fork返回,开始调度器调度
fork()---->clone()----->do_fork()---->copy_process():
1.dup_task_struc()拷贝父进程描述符
2.检查进程数目是否超出限制
3.设置子进程描述符,并保证子进程不被运行
4.get_pid为子进程获取有效pid
5.拷贝进程地址空间
fork返回两次的原因:
父子进程有相同的堆栈段,且两个进程都停留在fork函数中等待返回,一次是在父进程中返回,另一次是在子进程中返回
写时拷贝技术:
fork之后exec之前,两个进程的虚拟空间不同,物理空间相同。子进程的代码段、数据段、堆栈都是指向父进程的物理空间。
父子进程中有更改相应段的行为发生时,再为子进程相应的数据段、堆栈分配物理空间,但是代码段的物理空间共享。
子进程执行exec系统调用,代码段也会被单独分配物理空间。
通常内核将子进程放在队列前面,避免父进程导致写时拷贝,然后子进程exec,造成无意义的复制。
3.系统调用vfork()
创建一个子进程,而子进程和父进程共享地址空间。vfork保证子进程先运行,且必须调用exit()函数退出,保证main的函数栈不被子进程释放,从而父进程访问时发生段错误。
4.exec()函数族
fork函数创建一个子进程,代码段与父进程共享,几乎是父进程副本。
子进程可exec函数族执行另外的程序(二进制文件、脚本文件),取代原调用进程的数据段、代码段和堆栈段。但进程号不变。
比system()调用的效率要高,system函数构建子进程由子进程间接调用exec函数。
4.父子进程共享资源
共享的有:
打开的文件描述符、当前工作目录、信号屏蔽集、用户ID、所在组ID、共享内存
不共享的有:
进程ID、未决信号集,父进程的锁子进程不继承
进程终止
5种正常终止
- main函数return(相当于exit)
- exit()库函数,调用终止处理函数、信号处理函数、冲洗IO流缓冲区,而后终止
- _exit()、_Exit()系统调用,直接终止
- 最后一个线程return,但其返回值不作为进程返回值,进程终止状态为0
- 最后一个线程调用pthread_exit函数,进程终止状态为0
3种异常终止
- 进程调用abort,产生SIGABRT信号,异常终止自己
- 当进程接收到来自内核或者其他进程某些信号
- 最后一个线程响应cancellation,一个线程要求取消另一个线程,一段时间之后,目标线程终止
exit()、atexit()和_exit()
一个进程可以登记多达32个函数,这些函数将由exit自动调用。以下是登记接口
#include <stdlib.h>
int atexit( void (*func)(void) );
登记顺序和调用顺序相反,多次登记多次调用
exit函数总是执行一个标准I/O库的清理关闭操作:为所有打开流调用fclose函数。这会造成所有缓冲的输出数据都被冲洗(写到文件上)
wait()
进程结束时,内核释放进程资源如:打开文件、占用内存。但是pid、结束状态(退出or终止)、运行时间将保存,如果不释放pid会被占用。
unix保证父进程通过wait()获取子进程退出时的状态信息,并释放子进程保留信息。
孤儿进程和僵尸进程
孤儿进程
父进程退出,子进程还在运行,将成为孤儿进程,被init进程(进程号为1)所收养,由init进程对它们完成状态收集工作。
僵尸进程
子进程退出后父进程并没有调用wait或waitpid获取子进程的状态信息,子进程的进程描述符仍然保存在系统中。进程描述符不被释放将导致pid被占用。
查看杀死僵尸进程
$ps -A -o stat,ppid,pid,cmd | grep -e '^[Zz]'
-A 参数列出所有进程
-o 自定义输出字段
$kill -9 pid
避免僵尸进程
信号机制避免
子进程退出时会发出SIGCHILD信号,父进程设置信号处理函数,调用wait处理僵尸进程。
两次fork避免(不好)
fork出子进程,子进程在再fork子进程后退出,剩下的子进程将被init进程接收。
守护进程Daemon
运行在后台的一种特殊进程,独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。
守护进程启动方式
- 系统启动时由启动脚本/etc/rc.d启动
- cron 定时启动
- 终端下nohup启动
会话期、进程组
- 进程组(process group)
- 进程的集合
- 每个进程都有一个进程组ID
- 进程组ID是进程组长的pid
- 会话期(session)
- 进程组的集合
- 有唯一的会话首进程(session leader)
- 会话ID是会话首进程的pid
- 控制终端(controlling terminal)
- 会话期有单独一个控制终端
- 会话首进程是它的控制进程
Daemon编写
- 屏蔽一些控制终端操作的信号,防止守护进行在没有运行起来前,受到信号干扰退出或挂起
- 后台运行,fork(),父进程终止,子进程变成后台运行
- 脱离控制终端、登录会话和进程组,成为无终端的会话组长
- setsid();
- 禁止重新打开控制终端,不再成为会话组长
- 只有会话组长可以打开控制终端
- fork出子进程,使其不再是会话组长
- 关闭打开的文件描述符,避免系统资源浪费
- for(i=0; i< NOFILE; ++i) close(i);
- 改变当前工作目录为根目录,原所在目录的文件系统可以卸下
- chdir("/");
- 重设文件创建掩模,不继承父进程的存取权限
- umask(0);
- 处理 SIGCHLD 信号
- 服务器父进程需要处理子进程的结束,否则子进程将成为僵尸进程
- 但父进程等待子进程结束会增加父进程负担,影响服务进程性能
- 以SIG_IGN处理SIGCHLD,可以无负担防止子进程成为僵尸进程
进程调度算法
先来先服务 (FCFS,first come first served)
原理:
- 队列(FIFO),执行中不被抢先中断
优点:
- 简单,公平
缺点:
- 有利于CPU繁忙进程,不利于IO繁忙进程
最短作业优先(SJF, Shortest Job First)
原理:
- 预计执行时间短的进程优先分派处理机,执行中不被后来的更短进程抢先中断
优点:
- 可改善平均周转时间,缩短进程的等待时间
缺点:
- 长进程非常不利,且无优先级划分,难以估计进程时长
最高响应比优先法(HRRN,Highest Response Ratio Next)
原理:
- FCFS和SJF中和,同时考虑每个作业的等待时间长短和估计需要的执行时间长短
优点:
- 长进程有机会运行
缺点:
- 计算响应比,系统开销相应增加
计算:
- 响应比=(进程执行时间+进程等待时间)/ 进程执行时间
- 越大越优先
时间片轮转算法(RR,Round-Robin)
原理:
- 每个进程被分配一个时间片,即该进程允许运行的时间
- 让就绪进程以FCFS 的方式按时间片轮流使用CPU 的调度方式
- 一个时间片结束时,发生时钟中断,调度程序据此暂停当前进程的执行,将其送到就绪队列的末尾,切换执行队首进程
- 进程可以未使用完一个时间片,就让出CPU
优点:
- 简单易行、平均响应时间短
缺点:
- 不利于处理紧急作业
- 时间片的大小对系统性能的影响很大(几ms----几百ms)
时间片大小确定:
- 系统对响应时间的要求
- 就绪队列中进程的数目
- 系统的处理能力
多级反馈队列(Multilevel Feedback Queue)
算法描述:
-
每个队列按照时间片轮转算法调度
-
设置多个FIFO队列,具备不同优先级
-
高优先级队列的所有进程执行完,即队列为空,才会调度低优先级队列
-
高优先级队列时间片为N,其中一个任务尽过N个时间片未完成,将被调度到下一级队列
-
低优先级队列进程运行完时间片,高优先级队列不为空后,CPU分配给高优先级队列
进程和线程的区别
进程是对运行时程序的封装,是系统进行资源调度和分配的的基本单位。实现操作系统的并发
线程是进程的子任务,是CPU调度和分派的基本单位。实现进程内部的并发
线程独占:
- 寄存器组
- 指令计数器
- 处理器状态
- 线程栈
线程共享:
- 代码区、堆区、数据区
- 打开文件队列
区别:
- 一个线程只能属于一个进程,而一个进程可以有多个线程
- 进程在执行过程中拥有独立的内存单元,而多个线程共享进程的内存
- 进程是资源分配的最小单位,线程是CPU调度的最小单位
- 系统开销
- 进程创建或撤消,系统都要为之分配或回收资源(内存、IO),开销较大
- 进程切换涉及当前进程CPU环境的保存以及新被调度运行的进程的CPU环境的设置
- 线程切换只须保存和设置少量寄存器的内容
- 通信
- 线程具有相同的地址空间,致使它们之间的同步和通信的实现
- 进程间IPC相对复杂
- 调试
- 进程编程调试简单可靠性高,但是创建销毁开销大
- 线程正相反,开销小,切换速度快,但是编程调试相对复杂
- 进程间不会相互影响 ;线程一个线程挂掉将导致整个进程挂掉
- 进程适应于多核、多机分布;线程适用于多核
Linux的线程
Linux下不管是多线程编程还是多进程编程,最终都是用do_fork实现的多进程编程,只是进程创建时的参数不同,从而导致有不同的共享环境。
Linux线程在核内是以轻量级进程的形式存在的,拥有独立的进程表项,而所有的创建、同步、删除等操作都在核外pthread库中进行。
- do_fork() 提供了很多参数
- CLONE_VM(共享内存空间)
- CLONE_FS(共享文件系统信息)
- CLONE_FILES(共享文件描述符表)
- CLONE_SIGHAND(共享信号句柄表)
- 内核调用do_fork()不使用任何共享属性,进程拥有独立的运行环境
- pthread_create()来创建线程时,则最终设置了所有这些属性来调用__clone(),而这些参数又全部传给核内的do_fork(),从而创建的”进程”拥有共享的运行环境,只有栈是独立的
为什么要有线程
进程在同一时间只能干一件事 ,如果被阻塞,进程被挂起,程中有些工作不依赖于等待的资源但是无法执行。引入CPU调度粒度更小的线程,减少程序在并发执行时的时间开销,提高并发性。
线程的优势
- 资源,线程是一种借鉴的多任务操作方式,而进程需要独立的地址空间,且需要建立数据表维护它的代码段、堆栈段和数据段。
- 切换效率,同一进程中的多个线程之间使用相同地址空间,线程彼此切换所需时间远小于进程切换
- 通信机制,线程共享地址空间天然地可以实现线程间的通信,而进程间通信方式费时且不方便
- CPU充分利用,操作系统会保证当线程数不大于CPU数目时,不同的线程运行于不同的CPU
- 程序结构改善,复杂进程切分成几个线程,有利于理解和修改
线程间通信
-
临界区
多线程的串行化来访问公共资源
-
互斥锁
不能保证有序访问,常和条件变量一起使用
当进入临界区时,需要获得互斥锁并且加锁;当离开临界区时,需要对互斥锁解锁,以唤醒其他等待该互斥锁的线程
系统调用:
pthread_mutex_init:初始化互斥锁
pthread_mutex_destroy:销毁互斥锁
pthread_mutex_lock:以原子操作的方式给一个互斥锁加锁,如果目标互斥锁已经被上锁,pthread_mutex_lock调用将阻塞,直到该互斥锁的占有者将其解锁
pthread_mutex_unlock:以一个原子操作的方式给一个互斥锁解锁
-
条件变量
当某个共享数据达到某个值时,唤醒等待这个共享数据的一个/多个线程,线程操作共享变量时需要加锁。
系统调用
pthread_cond_init:初始化条件变量
pthread_cond_destroy:销毁条件变量
pthread_cond_signal:唤醒一个等待目标条件变量的线程。哪个线程被唤醒取决于调度策略和优先级
pthread_cond_wait:等待目标条件变量。需要一个加锁的互斥锁确保操作的原子性。该函数中在进入wait状态前首先进行解锁,然后接收到信号后会再加锁,保证该线程对共享资源正确访问
-
信号量
多个线程在同一时刻去访问同一个资源,但一般需要限制同一时刻访问此资源的最大线程数目
P(SV):如果信号量SV大于0,将它减一;如果SV值为0,则挂起该线程。
V(SV):如果有其他进程因为等待SV而挂起,则唤醒;否则直接将SV+1。
系统调用:
sem_wait(sem_t *sem):以原子操作的方式将信号量减1,如果信号量值为0,则sem_wait将被阻塞,直到这个信号量具有非0值
sem_post(sem_t *sem):以原子操作将信号量值+1。当信号量大于0时,其他正在调用sem_wait等待信号量的线程将被唤醒
-
事件(信号)wait/notify
PS命令查看线程状态
$ ps -T -p <pid>
ps命令中,“-T”选项可以开启线程查看
R 可执行,在可执行队列中
S 可中断睡眠,等待某事件发生而处于睡眠
D 不可中断睡眠,等待某事件发生而处于睡眠,不响应异步
T 暂停状态,追踪,响应SIGSTOP信号或处于gdb追踪
Z 退出状态,僵尸
X 退出状态,销毁
$ top -H -p <pid>
单核机器多线程是否加锁
需要,抢占式操作系统中,通常为每个线程分配一个时间片,当某个线程时间片耗尽时,操作系统会将其挂起,然后运行另一个线程。如果这两个线程共享某些数据,不使用线程锁的前提下,可能会导致共享数据修改引起冲突
游戏用户为什么是开启进程
因为同一进程间的线程会相互影响,一个线程死掉会影响其他线程,从而导致进程崩溃。因此为了保证不同用户之间不会相互影响,应该为每个用户开辟一个进程
线程池
生产者消费者模型
并发的线程数量很多,线程执行时间很短的任务就结束,频繁创建线程降低系统的效率,因为频繁创建线程和销毁线程需要时间,线程池使得线程可以复用,就是执行完一个任务,并不被销毁,而是可以继续执行其他的任务
任务进来时,首先执行判断,判断核心线程是否处于空闲状态,如果不是,核心线程就先就执行任务,如果核心线程已满,则判断任务队列是否有地方存放该任务,若果有,就将任务保存在任务队列中,等待执行,如果满了,在判断最大可容纳的线程数,如果没有超出这个数量,就开创非核心线程执行任务,如果超出了,就调用handler实现拒绝策略
AbortPolicy:不执行新任务,直接抛出异常,提示线程池已满
第二种DisCardPolicy:不执行新任务,也不抛出异常 第三种DisCardOldSetPolicy:将消息队列中的第一个任务替换为当前新进来的任务执行
第四种CallerRunsPolicy:直接调用execute来执行当前任务
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CHiV44vN-1587028203580)(C:\Users\cqlia\AppData\Roaming\Typora\typora-user-images\1585834863499.png)]
优点:
1、降低资源消耗。重复利用已创建线程,降低线程创建与销毁的资源消耗。
2、提高响应效率。任务到达时,不需等待创建线程就能立即执行。
3、提高线程可管理性。
4、防止服务器过载。内存溢出、CPU耗尽 。
协程
- 多线程同步模式,每来一个请求开一个线程来处理,这样进程的创建和销毁是一个非常大的开销,所以又演变为使用线程池来避免线程的频繁创建、销毁, 但是当请求并发量太大时线程的调度切换就成了瓶颈,并且操作系统对线程的最大数量有限制,所以这种模式不适合高并发场景。
- 基于
select/epoll
网络多路复用的异步模式,这种异步模式可以满足高并发需求,但是由于他是异步的,所以需要保存每次网络请求的上下文维护网络状态,基于状态调度完成请求。 这种模式的缺点是代码逻辑不清晰,难以阅读,状态之间相互依赖,代码的开发维护难度大(nginx
就是使用的这种模式)。 - 对于高并发这个问题微线程使用
epoll
多路复用来满足需求,对于异步的状态调度问题使用微线程调度来解决:当网络请求进入阻塞时保存当前微线程的上下文,并恢复下一个就绪微线程的上下文,完成微线程的调度切换,当没有就绪微线程的时候,则进入epoll_wait
阻塞整个进程。这样就实现了同步的编码逻辑、异步的执行效果
- 微线程是一种用户态
线程
,也就是说微线程的运行、调度、切换都是在用户空间由用户自己写的代码完成的,而传统线程的调度切换是在内核空间由操作系统完成的。 - 站在操作系统的角度来看微线程都是在单线程中运行的,所以无法利用CPU的多核资源,而传统线程是可以在多核上运行的,解决这个问题的办法一般是开多个进程同时运行
优点
- 第一微线程调度的时候不需要陷入内核;
- 第二微线程切换的开销相对于传统线程来说非常小,基本等于一次函数调用的开销;
- 第三微线程的调度时机是用户自己决定的,而传统线程的调度时机完全由内核决定;
每个协程也有一个对应的协程函数
协程函数:协程函数交出控制权后,可以再次从交出控制权的下一语句开始执行(类比多线程的调度方式)
协程函数特点
首次调用协程函数,会从堆中分配一个协程上下文,调用方的返回地址、入口函数、交出控制权等信息保存在协程上下文中
当协程中途交出控制权后,协程上下文不会被删除(相当于函数退出之后,上下文环境还被保存了,类比线程切换)
当协程再次获得控制权后,会自动从协程上下文中恢复调用环境,然后从上一次交出控制权的下一条语句继续执行(加载目标协程环境,类比线程切换)
协程函数返回(非中途交出控制权)后,协程上下文将被删除
若再次调用协程函数,视为首次调用
有栈协程
技术路线:一个线程可以创建大量协程,每个协程都会保留一个私有栈,协程一旦执行到异步IO处,就会主动交出控制权。同一线程的所有协程以串行方式协作执行,没有数据争用的问题
非对称协程调度:只允许协程将控制权交给主协程,主协程作为调度器分配执行权
**对称协程调度:**允许协程将控制权交给同一线程的其他协程
对称协程调度逻辑复杂,应用的场景有限,非对称协程是有栈协程的主流
无栈协程
技术路线:将异步IO封装到协程函数中,协程函数发起异步IO后,立即保存执行环境,然后吧控制权交给调用方(Caller),调用方继续往下执行;异步IO完成后,负责处理IO完成事件的回调函数获得控制权,回调函数再把控制权转交给发起IO的协程,发起IO的协程首先恢复执行环境,然后从上一次交出控制权的下一条语句继续往下执行
有栈协程和无栈协程对比
- 区别在于是否有自己的调用栈来进行函数调用等操作.
- 有栈协程的最大缺陷是保存调用栈的开销太大
- 无栈协程不但具有有栈协程的所有优点,而且空间开销极低;唯一不足就是需要语言标准和编译器支持
有栈协程上下文切换:
每个协程切换的时候, 整个栈都会被切换, 看起来和线程没啥区别, 只是调度一个发生在用户态可以由用户控制, 一个发生在内核态由系统控制.
,类比线程切换)
当协程再次获得控制权后,会自动从协程上下文中恢复调用环境,然后从上一次交出控制权的下一条语句继续执行(加载目标协程环境,类比线程切换)
协程函数返回(非中途交出控制权)后,协程上下文将被删除
若再次调用协程函数,视为首次调用
有栈协程
技术路线:一个线程可以创建大量协程,每个协程都会保留一个私有栈,协程一旦执行到异步IO处,就会主动交出控制权。同一线程的所有协程以串行方式协作执行,没有数据争用的问题
非对称协程调度:只允许协程将控制权交给主协程,主协程作为调度器分配执行权
**对称协程调度:**允许协程将控制权交给同一线程的其他协程
对称协程调度逻辑复杂,应用的场景有限,非对称协程是有栈协程的主流
无栈协程
技术路线:将异步IO封装到协程函数中,协程函数发起异步IO后,立即保存执行环境,然后吧控制权交给调用方(Caller),调用方继续往下执行;异步IO完成后,负责处理IO完成事件的回调函数获得控制权,回调函数再把控制权转交给发起IO的协程,发起IO的协程首先恢复执行环境,然后从上一次交出控制权的下一条语句继续往下执行
有栈协程和无栈协程对比
- 区别在于是否有自己的调用栈来进行函数调用等操作.
- 有栈协程的最大缺陷是保存调用栈的开销太大
- 无栈协程不但具有有栈协程的所有优点,而且空间开销极低;唯一不足就是需要语言标准和编译器支持
有栈协程上下文切换:
每个协程切换的时候, 整个栈都会被切换, 看起来和线程没啥区别, 只是调度一个发生在用户态可以由用户控制, 一个发生在内核态由系统控制.