同步、异步,并发、并行,线程、进程、协程

参考链接:线程,进程,协程, 并发,并行,同步,异步概念解析

基本概念

同步、异步

同步

异步 Async

并发、并行

并发 Concurrency

在一个只有单核(单 CPU)的处理器的操作系统中,同一时刻只能有一个进程运行。

假设只有一个进程运行,为了执行多任务,需要将 CPU的时间资源分为很多个时间片,将每个时间片分给一个线程,每个线程就可以执行不同的任务。
这样做的好处是,每个线程只占用一个时间片,当一个任务阻塞,当耗尽了内核分配给它的一个时间片后就会挂起,接着执行其他任务,后续再切换到阻塞的线程时也只能占用一个时间片的时间。
有些文章说,内核将时间片分给进程,其实不算准确,因为线程才是程序实际运行时的单元,进程只是一个容器,最少包含一个线程。当多个程序运行时,内核表面上是会将时间片分配给进程,但实际上是根据进程里的线程数分配时间的。

并行 Parallelism

现在的市面上已经没有单核处理器了,最低端的处理器也是多核(多 CPU)。与单核同样,每个核心同一时刻也只能运行一个进程。
同样假设每个核心只有一个进程,如果每个进程上都只运行一个程序(只开一个线程),这些程序因为是运行在不同的核心上,占用的不是同一个 CPU 资源,所以可以在 同一时刻运行 ,且互不干扰,这就是并行。

图解并发与并行

并发

假设我们有两个任务A和B,我们使用并发执行是这样的。
可以看到虽然A和B作为两个整体的任务但是未必会直接执行完,而是会在两个任务间来回切换。因为cpu切换的速度实在太快了,所以我们看起来好像是A和B在同时执行,但其实在每个时间点上只有一个任务在执行。
|870

并行

并行操作是指两个任务同时执行的。
|865

进程、线程、协程

进程 Process

是什么:

最小的资源分配单位。

上下文切换

电脑中每个软件的启动就代表一个进程,就是把写的程序加载到操作系统中来执行预定好的任务。操作系统会为进程分配相应的资源来支撑它完成任务,每个进程会分配一个唯一的PID。
|900

进程调度算法

非抢占式调度

​一个非常简单的想法就是让所有员工排队用这台计算机,轮到的这个员工一直使用到自己的所有工作都处理完,才让给下一个同事。
​操作系统调度到某个进程之后,不会对进程做任何干预,直到该进程阻塞或者结束, 才会切换到其他就绪的进程。
但如果轮到的这个员工处理完自己的工作需要 2 小时,但后几名员工都只需要几分钟, 这个排序效率就不够好了。

抢占式调度

​操作系统调度到某个进程之后会给它分配一个时间片,如果超过时间片还没有结束或者中途被阻塞,该进程会被操作系统挂起,调度其他进程来执行其他程序。

对比

非抢占式调度:更适合调度可以忍受延迟执行的普通进程。 简称:进程被cpu调度了
抢占式调度:更适合调度交互性要求高的实时进程。 简称:进程只有阻塞或者运行完成之后才能将cpu交给另一个进程

进程的三个状态

线程 Thread

是什么

线程是操作系统中最小的调度单位。
​ 线程是进程的子集,也称为轻量级进程。一个进程可以有多个线程,这些线程由调度器独立管理。一个进程内的所有线程都是相互关联的。

上下文切换

​线程没有自己的地址空间,同一进程的线程之间切换,他们共享同一进程的地址空间,所以只需要切换处理器状态;不同进程的线程之间切换,会引起进程切换
​由于同一进程下的线程上下文切换不引起虚拟地址空间切换,所以它们上下文切换的花销要比进程小很多

线程是进程的执行实例,是一个程序执行的最小单元,每个进程里的任务会有线程去具体执行。
|905

协程 Coroutine

是什么

轻量级线程

上下文切换

内存占用少,只要 2k,且上下文切换成本低,是一个独立执行的函数,由 go 语言启动

就像我们刚才说到的,任务的切换是操作系统来控制,我们有没有什么办法来减小这种开销呢?我们就可以使用协程,协程我们可以理解为轻量级的线程。 协程在执行过程中不会由操作系统直接操作,而是由编译器决定,比如协程A说我当前的任务还得一段时间执行完,我可以让出当前占用的资源了,协程A就会通知调度器,由调度器来分配下一个协程执行。 更多协程的信息可以参考这个链接。

进程、线程+单核、多核

coroutine协程详解

前两天阿里巴巴开源了coobjc,没几天就已经2千多star了,我也看了看源码,主要关注的是协程的实现,周末折腾了两整天参照Go的前身libtask和风神的coroutine实现了一部分,也看了一些文章,稍微整理一下。

协程

Coroutines are computer-program components that generalize subroutines for non-preemptive multitasking, by allowing multiple entry points for suspending and resuming execution at certain locations. Coroutines are well-suited for implementing familiar program components such as cooperative tasks, exceptions, event loops, iterators, infinite lists and pipes.

协程是一种计算机程序组件,它通过允许多个入口点在某些位置暂停和恢复执行,从而为非抢占式多任务推广子程序。 协程非常适合实现熟悉的程序组件,如协作任务、异常、事件循环、迭代器、无限列表和管道。

Process -> Thread -> Coroutine

协程(Coroutine)编译器级的,进程(Process)和线程(Thread)操作系统级的

进程(Process)和线程(Thread)是os通过调度算法,保存当前的上下文,然后从上次暂停的地方再次开始计算,重新开始的地方不可预期,每次CPU计算的指令数量和代码跑过的CPU时间是相关的,跑到os分配的cpu时间到达后就会被os强制挂起,开发者无法精确的控制它们。

协程(Coroutine)是一种轻量级的用户态线程,实现的是非抢占式的调度,即由当前协程切换到其他协程由当前协程来控制。目前的协程框架一般都是设计成 1:N 模式。所谓 1:N 就是一个线程作为一个容器里面放置多个协程。那么谁来适时的切换这些协程?

答案是有协程自己主动让出 CPU,也就是每个协程池里面有一个调度器,这个调度器是被动调度的。意思就是他不会主动调度。而且当一个协程发现自己执行不下去了(比如异步等待网络的数据回来,但是当前还没有数据到),这个时候就可以由这个协程通知调度器,这个时候执行到调度器的代码,调度器根据事先设计好的调度算法找到当前最需要 CPU 的协程。切换这个协程的 CPU 上下文把 CPU 的运行权交个这个协程,直到这个协程出现执行不下去需要等等的情况,或者它调用主动让出 CPU 的 API 之类,触发下一次调度。

优缺点

优点

协作式调度相比抢占式调度的优势在于上下文切换开销更少、更容易把缓存跑热。和多线程比,线程数量越多,协程的性能优势就越明显。进程 / 线程的切换需要在内核完成,而协程不需要,协程通过用户态栈实现,更加轻量,速度更快。在重 I/O 的程序里有很大的优势。比如爬虫里,开几百个线程会明显拖慢速度,但是开协程不会。

但协程也放弃了原生线程的优先级概念,如果存在一个较长时间的计算任务,由于内核调度器总是优先 IO 任务,使之尽快得到响应,就将影响到 IO 任务的响应延时。假设这个线程中有一个协程是 CPU 密集型的他没有 IO 操作,也就是自己不会主动触发调度器调度的过程,那么就会出现其他协程得不到执行的情况,所以这种情况下需要程序员自己避免。

此外,单线程的协程方案并不能从根本上避免阻塞,比如文件操作、内存缺页,这都属于影响到延时的因素。

协程方案基于事件循环方案,减少了同步加锁的频率。但若存在竞争,并不能保证临界区,因此该上锁的地方仍需要加上协程锁。

需要注意的是,协程的确可以减少 callback 的使用但是不能完全替换 callback。基于事件驱动的编程里面反而不能发挥协程的作用而用 callback 更适合。

缺点

适用场景

所以人们发明了异步 IO。就是当数据到达的时候触发我的回调。来减少线程切换带来性能损失。但是这样的坏处也是很大的,最大的问题就是破坏掉了人类这种线性的思维模式,你必须把一个逻辑上线性的过程切分成若干个片段,每个片段的起点和终点就是异步事件的完成和开始。固然经过一些训练你可以适应这种思维模式,但你还是要付出额外的心智负担。与人类的思维模式相对应,大多数流行的编程语言都是命令式的,程序本身呈现出一个大致的线性结构。异步回调在破坏点思维连贯性的同时也破坏掉了程序的连贯性,让你在阅读程序的时候花费更多的精力。这些因素对于一个软件项目来说都是额外的维护成本,所以大多数公司并不是很青睐 node.js 或者 RxJava 之类的异步回调框架,尽管这些框架能提升程序的并发能力。

但是协程可以很好解决这个问题。比如把一个 IO 操作 写成一个协程。当触发 IO 操作的时候就自动让出 CPU 给其他协程。要知道协程的切换很轻的。协程通过这种对异步 IO 的封装既保留了性能也保证了代码的容易编写和可读性。

消除 Callback Hell(回调地狱),使用同步模型降低开发成本的同时保留更灵活控制流的好处,比如同时发三个请求;这时节约地使用栈,可以充分地发挥 "轻量" 的优势。

原理和实现

ucontext

协程一般有两类实现,一种是 stackless,一种是 stackful。
structure

struct ucontext {
    /*
     * Keep the order of the first two fields. Also,
     * keep them the first two fields in the structure.
     * This way we can have a union with struct
     * sigcontext and ucontext_t. This allows us to
     * support them both at the same time.
     * note: the union is not defined, though.
     */
    sigset_t    uc_sigmask;  //这个上下文要阻塞的信号
    mcontext_tt uc_mcontextt;  //保存的上下文的特定机器表示,包括调用线程的特定寄存器等

    struct __ucontext *uc_link;  //指向当前的上下文结束时要恢复到的上下文
    stack_t     uc_stack;  //该上下文中使用的栈
    int     __spare__[8];  
};

getcontext

int getcontext(ucontext_t *ucp)

该函数初始化ucp所指向的结构体ucontext_t(用来保存前执行状态上下文),填充当前有效的上下文

setcontext

int setcontext(const ucontext_t *ucp)

函数恢复用户上下文为ucp所指向的上下文。成功调用不会返回。ucp所指向的上下文应该是getcontext()或者makecontext()产生的。

如果上下文是getcontext()产生的,切换到该上下文,程序的执行在getcontext()后继续执行。

如果上下文被makecontext()产生的,切换到该上下文,程序的执行切换到makecontext()调用所指定的第二个参数的函数上。当该函数返回时,我们继续传入makecontext()中的第一个参数的上下文中uc_link所指向的上下文。如果是NULL,程序结束。

成功时,getcontext()返回0,setcontext()不返回。错误时,都返回-1并且赋值合适的errno。

makecontext

void makecontext(ucontext_t *ucp, void (*func)(void), int argc, ...)

函数修改ucp所指向的上下文,ucp是被getcontext()所初始化的上下文。当这个上下文采用swapcontext()或者setcontext()被恢复,程序的执行会切换到func的调用,通过makecontext()调用的argc传递func的参数。

在makecontext()产生一个调用前,应用程序必须确保上下文的栈分配已经被修改。应用程序应该确保argc的值跟传入func的一样(参数都是int值4字节);否则会发生未定义行为。

当makecontext()修改过的上下文返回时,uc_link用来决定上下文是否要被恢复。应用程序需要在调用makecontext()前初始化uc_link。

swapcontext

int swapcontext(ucontext_t *restrict oucp, const ucontext_t *restrict ucp)

函数保存当前的上下文到oucp所指向的数据结构,并且设置到ucp所指向的上下文。
成功完成,swapcontext()返回0。否则返回-1,并赋值合适的errno。
swapcontext()函数可能会因为下面的原因失败:
ENOMEM ucp参数没有足够的栈空间去完成操作

ucontext协程的实际使用

将getcontext,makecontext,swapcontext封装成一个类似于lua的协同式协程,需要代码中主动yield释放出CPU。

协程的栈采用malloc进行堆分配,分配后的空间在64位系统中和栈的使用一致,地址递减使用,uc_stack.uc_size设置的大小好像并没有多少实际作用,使用中一旦超过已分配的堆大小,会继续向地址小的方向的堆去使用,这个时候就会造成堆内存的越界使用,更改之前在堆上分配的数据,造成各种不可预测的行为,coredump后也找不到实际原因。

对使用协程函数的栈大小的预估,协程函数中调用其他所有的api的中的局部变量的开销都会分配到申请给协程使用的内存上,会有一些不可预知的变量,比如调用第三方API,第三方API中有非常大的变量,实际使用过程中开始时可以采用mmap分配内存,对分配的内存设置GUARD_PAGE进行mprotect保护,对于内存溢出,准确判断位置,适当调整需要分配的栈大小。

风神的coroutine

风神的coroutine是基于ucontext封装的

schedule 调度器
struct schedule {
    char stack[STACK_SIZE]; // 原来schedule里面就已经存有了stack
    ucontext_t main; // ucontext_t你可以看做是记录上下文信息的一个结构
    int nco; // 协程的数目
    int cap; // 容量
    int running; // 正在运行的coroutine的id
    struct coroutine **co; // 这里是一个二维的指针
};

coroutine
struct coroutine {
    coroutine_func func; // 运行的函数
    void *ud; // 参数
    ucontext_t ctx; // 用于记录上下文信息的一个结构
    struct schedule * sch; // 指向schedule
    ptrdiff_t cap; // 堆栈的容量
    ptrdiff_t size; // 用于表示堆栈的大小
    int status;
    char *stack; // 指向栈地址么?
};

coroutine_new
int coroutine_new(struct schedule *S, coroutine_func func, void *ud)

创建一个协程,该协程的会加入到schedule的协程序列中,func为其执行的函数,ud为func的执行函数。返回创建的线程在schedule中的编号

coroutine_yield
void coroutine_yield(struct schedule * S)

挂起调度器schedule中当前正在执行的协程,切换到主函数。

coroutine_resume
void coroutine_resume(struct schedule * S, int id) { 

恢复运行调度器schedule中编号为id的协程

coroutine_close
void coroutine_close(struct schedule *S)
关闭schedule中所有的协程

Coroutine及其实现
协程(Coroutine)-ES中关于Generator/async/await的学习思考
ucontext-人人都可以实现的简单协程库
协程 及 libco 介绍
我所理解的ucontext族函数
ucontext簇函数学习
进程与线程4_协程
构建C协程之ucontext篇
协程:posix::ucontext用户级线程实现原理分析
ucontext-人人都可以实现的简单协程库