关于进程/线程/协程以及一些相关概念的整理

本文涉及到的一些概念

  • 多线程发展史
  • 并发与并行
  • CPU的密集型(CPU-Bound)
  • IO密集型 (I/O Bound)
  • 各种IO模型
  • 各种并发模型

多线程发展史

起因:如何最大化的利用CPU

CPU运算速度和IO速度的不平衡一直是计算机优化的一个课题,我们都知道CPU运算速度要以百倍千倍程度快于IO的速度,而在进行任务的执行的时候往往都会需要进行数据的IO(涉及硬盘io,网络io,内存io,cpu缓存io等),正因为这种速度上的差异,所以当CPU和IO一起协作的时候就产生问题了,CPU执行速度非常快,一个任务执行时候大部分时间都是在等待IO工作完成,在等待IO的过程中CPU是无法进行其它工作的,所以这样就使得CPU的资源根本无法合理的运用起来。

单任务时代

最初的计算机, 由程序员首先把程序写到纸上,然后穿孔成卡片,再把卡片输入到计算机上,计算机计算得到结果打印出来,程序员最后拿到打印出来的结果。这个过程中计算机大部分时间都处于等待指令输入的闲置状态,从而使造成计算机资源浪费。

为了更有效的利用计算机资源,不让它处于闲置状态,所以出现了批处理,批处理是对指令的输入上做了优化,批处理是可以一下把非常多的指令录入一个磁带内,这样减少了指令从打印到交给计算机读取的过程, 然后我们只需要不间断的交给计算磁带,计算机就资源就更好的利用了起来。

多进程时代

批处理虽然提高了计算机整体资源利用的问题,但是这个时代的计算机还只能开启一个任务,一台计算器同时只能干一件事情,进行计算的时候就不能进行读写的IO。这样就造成了CPU所花的计算时间非常少,而大部分的时间都在等待IO的结束。

为了更合理的利用CPU资源,我们就把内存划分为多个块,不同的应用程序使用着各自的内存空间互不干涉,这里单独的应用程序也就单独分为了一个进程,CPU可以在多个进程之间切换这执行,当一个进程需要进行磁盘IO的时候CPU就切换到另外一个进程去执行指令,这样就让CPU的资源更合理的运用起来,这个时候随着内存的加大,可划分的块也越来越多,这样能“同时”运行的进程也越来越多,CPU在不同的进程之间切换执行,任务多的时候它会一直处于工作状态。

当然随着CPU工作模式的变换,同时也衍生了一些概念。

  • CPU时间片 : 为了公平的照顾到每个进程,每一次切换进程后,它都会只会工作一段时间,然后又切换到另外一个进程任执行一段时间,而我们把CPU切换进程任务的这个时间段叫做“时间片”。
  • 程序计数器 : 因为CPU切换任务后,下次回来需要知道当前任务已经执行到了哪一步,所以对任务节点进行一个标记,回来的时候可以从上次标记的位置继续工作,而这个标记叫做程序计数器。
  • 堆栈指针 : 每个进程分配的内存是独立的,所以切换进程任务回来后我们需要知道当前进程分配的内存地址是那一块,而这个地址就是堆栈指针。

多线程时代

为了精益求精,我们发现基于进程的调度还是有改善的空间。因为CPU是基于进程切换的,一个进程工作时那么就意味着其他进程无法获得CPU资源。另外当一个进程任务执行一个IO时间较长的指令时,那些与IO无关系的操作也必须等待IO执行完才能执行。

所以我们的CPU选择了基于粒度更小的线程来调度执行任务。一个进程可以创建很多个线程来执行任务,没有了进程的界限区分,CPU会在线程之间来回切换的工作, CPU的时间片分的更细,以至他在多个线程之间切换执行,我们并没有明显的感知,这样的话我们多个进程也变得可以“同时”工作了,同时CPU等待IO的几率又更加小了。

多核CPU

随着应用程序越来越丰富,我们反而感觉到CPU不够用了, 既然一个CPU不够用,那么我们是不是可以多加几个CPU来弥补呢,所以就出现了现在的双核、四核、八核.....甚至更多核心控制器的CPU。

之前我们只有一个CPU,所以在某一个时间片之内永远只会有一个线程执行指令,因为时间片的单位太小以至于我们感知不到,以为多个程序的线程是“同时”执行的,但是进入了多核CPU时代后,多核心控制器可以同一时间执行不通过线程上的任务,从而真正意义上实现了多任务同时执行。

CPU缓存

CPU觉得每次去内存里面读写数据太慢了,为了更快的读写数据,所以CPU在自己家里加了一层缓存,每次都是先从自己的缓存读写数据,这样CPU的利用率又提高了.

并发与并行

并发(concurrent):当有多个线程在操作时,如果系统只有一个CPU,则它根本不可能真正同时进行一个以上的线程,它只能把CPU运行时间划分成若干个时间段,再将时间 段分配给各个线程执行,在一个时间段的线程代码运行时,其它线程处于挂起状。这种方式我们称之为并发。

并行(Parallel):当系统有一个以上CPU时,则线程的操作有可能非并发。当一个CPU执行一个线程时,另一个CPU可以执行另一个线程,两个线程互不抢占CPU资源,可以同时进行,这种方式我们称之为并行。

进程(Process)

程序运行时系统就会创建一个进程,并为它分配资源,然后把该进程放入进程就绪队列,进程调度器选中它的时候就会为它分配CPU时间,程序开始真正运行。进程有独立的地址空间,在保护模式下自己出了问题不会对其他进程产生影响。进程是操作系统分配资源的基本单位

执行一段程序代码,实现一个功能的过程介绍 ,当得到CPU的时候,相关的资源必须也已经就位. 这里除了CPU以外所有的就构成了这个程序的执行环境,也就是我们所定义的程序上下文。当这个程序执行完了,或者分配给他的CPU执行时间用完了,那它就要被切换出去,等待下一次CPU的临幸。在被切换出去的最后一步工作就是保存程序上下文,因为这个是下次他被CPU临幸的运行环境,必须保存。

前面讲过在CPU看来所有的任务都是一个一个的轮流执行的,具体的轮流方法就是:先加载程序A的上下文,然后开始执行A,保存程序A的上下文,调入下一个要执行的程序B的程序上下文,然后开始执行B,保存程序B的上下文。。。。

进程就是包换上下文切换的程序执行时间总和 = CPU加载上下文+CPU执行+CPU保存上下文

进程的三种状态:

请输入图片描述

  • 阻塞态:又称等待状态,等待某个事件完成的过程。因为等待某一事件而暂停运行,所以阻塞。如等待某资源为可用(不包括处理机)或等待输入/输出完成。即使处理机空闲,该进程也不能运行。

  • 就绪态:等待系统分配CPU以便运行,进程已处于准备运行的状态,即进程获得了除处理机之外的一切所需资源,一旦得到处理机即可运行。

  • 执行态:占有CPU正在运行。进程正在处理机上运行。在单处理机(一个CPU)环境下,每一时刻最多只有一个进程处于运行状态。

就绪->执行, 当前运行进程阻塞,调度程序选一个优先权最高的进程占有处理机;
执行->就绪, 当前运行进程时间片用完;
执行->等待,当前运行进程等待键盘输入,进入了睡眠状态。
等待->就绪,I/O操作完成,被中断处理程序唤醒。

进程通信

  • 通过消息Queue: 消息队列是内容为消息的链表

  • 通过管道Pipe:管道是一种半双工的通信方式,数据只能单向流动

  • 通过共享内存:也就是所有进程可以在一段共有的内存上访问数据。

线程(Thread)

每一个进程至少有一个主执行线程,它无需由用户去主动创建,是由系统自动创建的。用户根据需要在应用程序中创建其它线程,多个线程并发地运行于同一个进程中。

线程是程序执行时的最小单位,它是进程的一个执行流,线程是CPU调度和分派的基本单位

线程可以共享进程的资源,但是每个线程都有自己的程序计数器局部变量表。同一进程中的不同线程能够访问相同的变量,并且在同一个上分配对象。

因为进程做任务切换需要切换内存映射地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了,现代的操作系统都基于更轻量的线程来调度,现在我们提到的“任务切换”都是指“线程切换”。

引入进程的目的,是为了使多道程序并发执行,以提高资源利用率和系统吞吐量;

引入线程,则是为了减小程序在并发执行时所付出的时空开销,提高操作系统的并发性能。

多线程中堆和栈区

我们通常说的堆栈有两种场景:

程序内存布局场景下,堆与栈表示两种内存管理方式;这里是目前讨论的场景
数据结构场景下,堆与栈表示两种常用的数据结构。

由开发人员分配和释放, 若开发人员不释放,程序结束时由OS回收,分配方式类似于链表。关于堆上内存空间的分配过程,首先应该知道操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表(全局堆),寻找第一个空间大于所申请空间的堆节点,然后将该节点从空闲节点链表中删除,并将该节点的空间(局部堆)分配给程序。

由操作系统自动分配释放,用于存放函数的参数值、局部变量等,其操作方式类似于数据结构中的栈。

堆:堆是大家共有的空间,分全局堆局部堆。全局堆就是所有没有分配的空间,局部堆就是用户分配的空间。堆在操作系统对进程初始化的时候分配,运行过程中也可以向系统要额外的堆,但是记得用完了要还给操作系统,要不然就是内存泄漏。

栈:栈是个线程独有的,保存其运行状态和局部自动变量的。栈在线程开始的时候初始化,每个线程的栈互相独立,因此,栈是 thread safe的。操作系统在切换线程的时候会自动的切换栈,就是切换 SS/ESP寄存器。栈空间不需要在高级语言里面显式的分配和释放。

单核CPU上运行的多线程程序, 同一时间只能一个线程在跑, 系统帮你切换线程而已, 系统给每个线程分配时间片来执行, 每个时间片大概10ms左右, 看起来像是同时跑, 但实际上是每个线程跑一点点就换到其它线程继续跑,上下文的切换更影响效率。

所以当某项工作大多时进行的都是cpu运算,那么多线程显然毫无意义,而且还会增加线程之间切换保存线程的额外工作,所以cpu密集型的工作不适合用线程。

线程的实现方式

  • 用户级线程
  • 内核级线程

用户级线程中

通常,应用程序从单线程起始,在该线程中开始运行,在其运行的任何时刻,可以通过调用线程库中的派生例程创建一个在相同进程中运行的新线程。

内核级线程中

在内核级线程中,线程管理的所有工作由内核(Kernel)完成,应用程序没有进行线程管理的代码,只有一个到内核级线程的编程接口。内核为进程及其内部的每个线程维护上下文信息,调度也是在内核基于线程架构的基础上完成。

协程

通俗易懂的回答:让原来要使用异步+回调方式写的非人类代码,可以用看似同步的方式写出来...
说协程性能好的,其实真正的原因是因为瓶颈在IO上面,而这个时候真正发挥不了线程的作用。

协程的概念,从一定程度来讲,可以说是“用同步的语义解决异步问题”,即业务逻辑看起来是同步的,但实际上并不阻塞当前线程(一般是靠事件循环处理来分发消息)。协程就是用来解决异步逻辑的编程复杂度问题的。

人们发现 像 Web Server 这种东西, 完全是靠IO嘛, Thread Per Message 完全可以一波流, 但创建和销毁 Thread 成本依旧很高, 于是协程这种东西也就又开始流行了

优点

  • 协程更加轻量,创建成本更小,降低了内存消耗
  • 协程有自己的调度器,减少了 CPU 上下文切换的开销,提高了 CPU 缓存命中率
  • 减少同步加锁,整体上提高了性能
  • 可以按照同步思维写异步代码,即用同步的逻辑,写由协程调度的回调

缺点

  • 在协程执行中不能有阻塞操作,否则整个线程被阻塞
  • 协程可以处理 IO 密集型程序的效率问题,但不适合处理 CPU 密集型问题

适用场景

  • 高性能计算,牺牲公平性换取吞吐。

  • 在 IO 密集型的任务

  • Generator式的流式计算

  • 网络请求,比如爬虫,大量使用 aiohttp

  • 文件读取, aiofile

  • web 框架, aiohttp, fastapi

  • 数据库查询, asyncpg, databases

真正让协程大放异彩的是它在IO多路复用中的应用,二者的结合是一种炙手可热的高并发解决方案。

协程的性能主要体现在IO,协程的本质其实就是可以被暂停以及可以被恢复的函数,若一个IO操作比较消耗时间,CPU可以换到另一个协程上运行,以免CPU时间在忙等中被白白浪费。

协程(Coroutine)是一种轻量级的用户级线程,实现的是非抢占式的调度,即由当前协程切换到其他协程由当前协程来控制。目前的协程框架一般都是设计成 1:N 模式。所谓 1:N 就是一个线程作为一个容器里面放置多个协程。那么谁来适时的切换这些协程?答案是有协程自己主动让出 CPU,也就是每个协程池里面有一个调度器,这个调度器是被动调度的。意思就是他不会主动调度。而且当一个协程发现自己执行不下去了(比如异步等待网络的数据回来,但是当前还没有数据到),这个时候就可以由这个协程通知调度器,这个时候执行到调度器的代码,调度器根据事先设计好的调度算法找到当前最需要 CPU 的协程。切换这个协程的 CPU 上下文把 CPU 的运行权交个这个协程,直到这个协程出现执行不下去需要等等的情况,或者它调用主动让出 CPU 的 API 之类,触发下一次调度。

协程使用时需要注意的事项

协程运行在线程之上,并且协程调用了一个阻塞IO操作,这时候会发生什么?实际上操作系统并不知道协程的存在,它只知道线程,因此在协程调用阻塞IO操作的时候,操作系统会让线程进入阻塞状态,当前的协程和其它绑定在该线程之上的协程都会陷入阻塞而得不到调度,这往往是不能接受的。

因此在协程中不能调用导致线程阻塞的操作。也就是说,协程只有和异步IO结合起来,才能发挥最大的威力

那么如何处理在协程中调用阻塞IO的操作呢?一般有2种处理方式:

  • 在调用阻塞IO操作的时候,重新启动一个线程去执行这个操作,等执行完成后,协程再去读取结果。这其实和多线程没有太大区别。
  • 对系统的IO进行封装,改成异步调用的方式,这需要大量的工作,最好寄希望于编程语言原生支持。

协程对计算密集型的任务也没有太大的好处,计算密集型的任务本身不需要大量的线程切换,因此协程的作用也十分有限,反而还增加了协程切换的开销。

import asyncio
import time
import requests


async def func1():
    print(1)
    await asyncio.sleep(3)  # 遇到耗时后会自动切换到其他函数中执行
    print(2)


async def func2():
    print(3)
    # sleep(5) web请求阻塞了, 导致整个线程阻塞
    # 所以协程必须配合异步使用,不然整个线程阻塞,等其执行完后才会继续执行,那么协程就没意义了
    res = requests.get("https://signup.jobshaigui.com/bbx2018") 
    print(res.text)
    print(4)


async def func3():
    print(5)
    await asyncio.sleep(2)
    print(6)


async def main():
    task1 = asyncio.create_task(func1())
    task2= asyncio.create_task(func2())
    task3 = asyncio.create_task(func3())
    await task1
    await task2
    await task3

# 协程函数使用 func1()这种方式是执行不了的
start = time.time()
print(10)
asyncio.run(main())
print(12)
print(time.time() - start)  # 只会等待3秒
print(13)

# 此代码运行后返回:
# 10
# 1
# 3
# this is website content
# 4
# 5
# 2
# 6
# 12
# 7.068994998931885
# 13



# 异步http请求
import asyncio
import time
import requests
import aiohttp


async def func1():
    print(1)
    await asyncio.sleep(3)  # 遇到耗时后会自动切换到其他函数中执行
    print(2)


async def func2():
    print(3)
    # sleep(5) 异步web请求, 未阻塞
    async with aiohttp.request('GET', 'https://signup.jobshaigui.com/bbx2018') as r:
        test = await r.text()
        print(test)
    print(4)


async def func3():
    print(5)
    await asyncio.sleep(2)
    print(6)


async def main():
    # 注意这里需要先赋值到task1然后再执行await
    # 这种写法会导致同步执行: await asyncio.create_task(func1())
    task1 = asyncio.create_task(func1())
    task2= asyncio.create_task(func2())
    task3 = asyncio.create_task(func3())
    await task1
    await task2
    await task3

# 协程函数使用 func1()这种方式是执行不了的
start = time.time()
print(10)
asyncio.run(main())
print(12)
print(time.time() - start)  # 只会等待3秒
print(13)

# 此代码运行后返回:
# 10
# 1
# 3
# 5
# 6
# 2
# this is website content
# 4
# 12
# 5.090999126434326
# 13

以上代码可以得出一个结论: 在协程中尽量不要调用阻塞IO的方法,比如打印,读取文件,Socket接口等,除非改为异步调用的方式,并且协程只有在IO密集型的任务中才会发挥作用。协程只有和异步IO结合起来才能发挥出最大的威力。协程如果执行阻塞IO会导致整个进程阻塞, 从整个系统角度上来说, 这时候此线程就会让出CPU时间去执行其他线程了,此线程挂起,直到阻塞IO完成.又会浪费一定时间.

GO语言中的协程与Python中的协程的区别?

go的协程本质上还是系统的线程调用,而Python中的协程是eventloop模型实现,所以虽然都叫协程,但并不是一个东西.

  • golang的协程是基于gpm机制,是可以利用多核的。Python的协程是eventloop模型(IO多路复用技术)实现,协程是严格的 1:N 关系,也就是一个线程对应了多个协程。虽然可以实现异步I/O,但是不能有效利用多核(GIL)。
  • golang的协程可以适用IO密集和CPU密集中。Python的协程适用于IO密集程序中。
  • golang用go func。python用import asyncio,async/await表达式。
  • 从通信上来说, python协程不需要通讯,因为单线程的. golang由channel完成。

python协程的特点:

python的协程其实是我们通常意义上的协程Goroutine,执行过程中,在子程序(或者说函数)内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。

Python的协程源于yield指令, yield有两个功能: 1)yield item用于产出一个值,反馈给next()的调用方。 2)作出让步,暂停执行生成器,让调用方继续工作,直到需要使用另一个值时再调用next()。

和大多数语言一样,在 Python 中,协程的调度是非抢占式的,也就是说一个协程必须主动让出执行机会,其他协程才有机会运行。
让出执行的关键字就是 await。也就是说一个协程如果阻塞了,持续不让出 CPU,那么整个线程就卡住了,没有任何并发。

  • 单线程内切换,适用于IO密集型程序中,可以最大化IO多路复用的效果。
  • 无法利用多核。
  • 协程间完全同步,不会并行。不需要考虑数据安全。
  • 用法多样,可以用在web服务中,也可用在pipeline数据/任务消费中

go协程的特点:

golang的协程兼具协程和线程的优势,从语言层面支持并发,同样是在适当的时候可中断可恢复。go模式可多个协程在多个线程上切换,既可以用到多核,又可以减少切换开销。

  • 协程间需要保证数据安全,比如通过channel或锁。
  • 可以利用多核并行执行。
  • 协程间不完全同步,可以并行运行,具体要看channel的设计。
  • 抢占式调度,可能无法实现公平。

进程/线程/协程各自有什么区别

  • 进程/线程的切换CPU执行权的者是操作系统,切换时机是根据操作系统自己的切换策略来决定的, 进程和线程切换的代价不同,用户是无感的。协程的切换者是用户(编程者或应用程序),切换时机是用户自己的程序来决定的。
  • 进程是资源分配的最小单位,线程是程序执行的最小单位。
  • 进程有自己的独立地址空间,每启动一个进程,系统就会为它分配地址空间,建立数据表来维护代码段、堆栈段和数据段,这种操作非常昂贵。线程是共享进程中的数据的,使用相同的地址空间,因此CPU切换一个线程的花费远比进程要小很多,同时创建一个线程的开销也比进程要小很多。
  • 线程之间的通信更方便,同一进程下的线程共享全局变量、静态变量等数据。进程之间的通信需要以通信的方式(IPC)进行。不过如何处理好同步与互斥是编写多线程程序的难点。
  • 线程本身拥有很少资源(线程标识符、程序计数器、一组寄存器的值、堆栈),与同属进程的其他线程共享进程拥有的资源(代码段、数据段、打开的文件、I/O设备等)。
  • 进程间通信(IPC)需要进程同步和互斥手段的辅助,以保证数据的一致性,而线程间可以直接读/写进程数据段(如全局变量)来进行通信。

并发编程bug的罪魁祸首(非常重要)

并发问题根源之一:CPU切换线程执导致的原子性问题

首先我们先理解什么叫原子性,原子性就指是把一个操作或者多个操作视为一个整体,在执行的过程不能被中断的特性原子性。也就是说在执行过程中不能被切换,必须执行完后才能切换.

简单如i++这么一个操作在计算器中都不是原子的,它相当于语句i=i+1;这里包括读取i,i+1,结果写入内存三个操作单元,单线程还好,虽然是三条指令,但因为是单线程,就可近似于原子操作,然而多线程的情况下,因为线程切换, 在读取并执行i+1后,线程切换到了另一个线程同样读取i,并执行i++后写入内存了,线程切换回来后继续执行写入到内存, 这样就导致i值被覆盖了,就不可能保证原子了。

并发问题根源之二:缓存导致的可见性问题

为了减少CPU等待IO的时间,让昂贵的CPU资源充分利用起来,提升计算机效率,其中一个思路就是减少IO等待的时间,所以就在CPU的基础上CPU级别的缓存(L1,L2,L3 缓存)。

在有了高速缓存之后,CPU的执行操作数据的过程会是这样的,CPU首先会从内存把数据拷贝到CPU缓存区。然后CPU再对缓存里面的数据进行更新等操作,最后CPU把缓存区里面的数据更新到内存

缓存导致的可见性问题就是指我们在操作CPU缓存过程中,由于多个CPU缓存之间独立不可见的特性,导致共享变量的操作结果无法预期, 如上例中的 i++例子, 两个核心同时读取了i并执行i+1,那么就会产生问题。

在单核CPU时代,因为只有一个核心控制器,所以只会有一个CPU缓存区,这时各个线程访问的CPU缓存也都是同一个,在这种情况一个线程把共享变量更新到CPU缓存后另外一个线程是可以马上看见的,因为他们操作的是同一个缓存,所以他们操作后的结果不存在可见性问题。

多核CPU每个核心控制器工作的时候都会有自己独立的CPU缓存,每个核心控制器都执行任务的时候都是操作的自己的CPU缓存,CPU1与CPU2它们之间的缓存是相互不可见的。这种情况下多个线程操作共享变量就因为缓存不可见而带来问题,多线程的情况下线程并不一定是在同一个CUP上执行,它们如果同时操作一个共享变量会导致问题出现.

案例: 变量累加1000次, 两个线程同时执行, 可能最后的结果不是2000

并发问题根源之三:指令优化导致的重排序问题

进程和线程本质上是增加并行的任务数量来提升CPU的利用率,缓存是通过把IO时间减少来提升CPU的利用率,而指令顺序优化的初衷就是想通过调整CPU指令的执行顺序和异步化的操作来提升CPU执行指令任务的效率。

指令顺序优化可能发生在编译、CPU指令执行、缓存优化几个阶段,其优化原则就是只要能保证重排序后不影响单线程的运行结果,那么就允许指令重排序的发生。其重排序的大体逻辑就是优先把CPU比较耗时的指令放到最先执行,然后在这些指令执行的空余时间来执行其他指令,就像我们做菜的时候会把熟的最慢的菜最先开始煮,然后在这个菜熟的时间段去做其它的菜,通过这种方式减少CPU的等待,更好的利用CPU的资源。

解决上述问题

  • 并发编程的罪魁祸首有三个,它们分别是:可见性,原子性以及有序性
  • 可见性问题是cpu缓存造成的,有序性问题是编译优化造成的,所以它们都可以通过volatile关键字来解决并发问题
  • 原子性问题可通过cas解决
  • 加锁可简单粗暴的解决此三类问题

IO模型

IO指的是输入输出的意思, 任何一个设备都有输入输出模块, 当然程序中我们通常讨论的是内存的输入输出. 程序通常不能直接操作设备IO, 必须通过系统调用,请求kernel来协助完成I/O动作。线程和内核的交互, 内核与设备的交互.

同步和异步 (针对被调用方)

由于CPU和内存的速度远远高于外设的速度,所以在IO编程中,就存在速度严重不匹配的问题。

同步请求:A调用B,B在处理完之前他不会通知A,只有处理完之后才会明确的通知A, 期间A一直处于等待B完成的状态。

异步请求:A调用B,B在接到请求后先告诉A我已经接到请求了, A收到后可以先去处理其他任务,同时B也去处理收到的任务,处理完之后通过回调等方式再通知A。

同步和异步最大的区别就是被调用方执行方式返回时机。同步指的是被调用方做完事情之后再返回,异步指的是被调用方先返回,然后再做事情,做完之后再想办法通知调用方。

阻塞和非阻塞 (针对调用方)

阻塞请求:A调用B,A一直等着B的返回,别的事情什么也不干。

非阻塞请求:A调用B,A不用一直等着B的返回,先去忙别的事情了。

阻塞和非阻塞最大的区别就是在被调用方返回结果之前的这段时间内,调用方 是否一直等待。阻塞指的是调用方一直等待别的事情什么都不做。非阻塞指的是调用方先去忙别的事情。

同步和异步的本质是我轮询你还是你回调我
阻塞和非阻塞的本质是当发生等待的时候我能不能干其它的事

I/O 是分为两个过程的

  • 数据准备的过程

  • 数据从内核空间拷贝到用户进程缓冲区的过程

根据上面两个步骤的不同,IO操作可以进一步细分为五种:

  • 阻塞IO模型(同步阻塞)
  • 非阻塞IO模型(同步非阻塞)
  • IO复用模型(一般为异步阻塞IO, io的两个过程阻塞或非阻塞)
  • 信号驱动IO模型(异步非阻塞, 并不多见)
  • 异步IO模型(异步非阻塞)

20200605193953223.png

对于大多数IO密集型的应用程序,使用异步IO将大大提升系统的多任务处理能力。

阻塞IO模型

进程发起IO系统调用后,进程被阻塞,转到内核空间处理,整个IO处理完毕后返回进程,操作成功则进程获取到数据。

非阻塞IO模型

和上面的阻塞IO模型相比,非阻塞IO模型在内核数据没准备好,需要进程阻塞的时候,就返回一个错误,以使得进程不被阻塞。这种工作方式下需要不断轮询查看状态. 消耗CPU时间.

IO复用模型

多个IO可以注册到一个复用器(select)上,然后用一个进程调用该select,select会监听所有注册进来的IO。

如果select监听的IO在内核缓冲区都没有可读数据,select调用进程会被阻塞;而当任一IO在内核缓冲区中有可读数据时,select调用就会返回;而后select调用进程可以自己或通知另外的进程(注册进程)来再次发起读取IO,读取内核中准备好的数据。
Linux中IO复用的实现方式主要有Select,Poll和Epoll:

Select:注册socket到内核空间, 有数据产生时遍历socket集合, 监听的IO最大连接数不能多于FD_ SIZE(1024), 2次遍历socket集合, 2次拷贝socket集合。
Poll:原理和Select相似,没有数量限制,但IO数量大,线性扫描性能下降。
Epoll :事件驱动的机制, 内核里维护了一个链表来记录就绪事件,数据就绪后不像select要轮询是哪个链接的数据就绪了, epoll事件通知哪些就绪了, , Linux2.6后内核支持。

select 的问题是:

  • 调用 select 时会陷入内核,这时需要将参数中的 fd_set 从用户空间拷贝到内核空间,高并发场景下这样的拷贝会消耗极大资源;(epoll 优化为不拷贝)
  • 进程被唤醒后,不知道哪些连接已就绪即收到了数据,需要遍历传递进来的所有 fd_set 的每一位,不管它们是否就绪;(epoll 优化为异步事件通知)
  • select 只返回就绪文件的个数,具体哪个文件可读还需要遍历;(epoll 优化为只返回就绪的文件描述符,无需做无效的遍历)
  • 同时能够监听的文件描述符数量太少,是 1024 或 2048;(poll 基于链表结构解决了长度限制)

poll 问题是:

  • poll 只是基于链表的结构解决了最大文件描述符限制的问题,其他 select 性能差的问题依然没有解决;终极的解决方案是 epoll,解决了 select 的前三个缺点;
  • epoll 的实现原理看起来很复杂,其实很简单,注意两个回调函数的使用:数据到达 socket 的等待队列时,通过回调函数 ep_poll_callback 找到 eventpoll 对象中红黑树的 epitem 节点,并将其加入就绪列队 rdllist,然后通过回调函数 default_wake_function 唤醒用户进程 ,并将 rdllist 传递给用户进程,让用户进程准确读取就绪的 socket 的数据。这种回调机制能够定向准确的通知程序要处理的事件,而不需要每次都循环遍历检查数据是否到达以及数据该由哪个进程处理,日常开发中可以学习借鉴下这种思想。

拓展1:

拓展2: IO复用模型的设计并不是为了快,而是为了解决线程、进程数量过多(数量过多会导致系统频繁切换cpu资源,造成操作系统压力)对服务器开销造成的压力。

信号驱动IO模型

当进程发起一个IO操作,会向内核注册一个信号处理函数,然后进程返回不阻塞;当内核数据就绪时会发送一个信号给进程,进程便在信号处理函数中调用IO读取数据。

异步IO模型

当进程发起一个IO操作,进程返回(不阻塞),但也不能返回结果。内核把整个IO处理完后,会通知进程结果,如果IO操作成功则进程直接获取到数据。

注意:
此模型和前面模型最大的区别是:前4个从内核空间拷贝数据这一过程是阻塞的,需要自己把准备好的数据,放到用户空间。
异步IO是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程都不用等待。用户线程完全不需要关心实际的整个IO操作是如何进行的,只需要先发起一个请求,当接收内核返回的成功信号时表示IO操作已经完成,可以直接去使用数据,它是最理想的模型。

多线程虽然解决了并发问题,但是线程数量过多,CPU的时间就花在线程切换上了,所以引入了异步IO,如果充分利用的异步IO,就可以用单进程单线程模型来执行多任务,这种全新的模型称为事件驱动模型,Nginx就是支持异步IO的Web服务器。在多核CPU上,可以运行多个进程(数量与CPU核心数相同),充分利用多核CPU,这样总的进程数量并不多,操作系统调度非常高效。

异步IO模型需要一个消息循环,在消息循环中,主线程不断地重复“读取消息-处理消息”这一过程,也就是说在“发出IO请求”到收到“IO完成”的这段时间里,异步IO模型的主线程并没有休息,而是在消息循环中继续处理其他消息,而不是像同步IO模型下,主线程只能挂起。这样,一个线程就可以同时处理多个IO请求,并且没有切换线程的操作。

异步IO使用场景
1、不涉及共享资源,或对共享资源只读,即非互斥操作
2、没有时序上的严格关系
3、不需要原子操作,或可以通过其他方式控制原子性
4、常用于IO操作等耗时操作,因为比较影响客户体验和使用性能
5、不影响主线程逻辑

CPU 密集型程序创建多少个线程合适?

对 CPU密集型,线程数量 = CPU 核数(逻辑)就可以了,但是实际上,数量一般会设置为 CPU 核数(逻辑)+ 1, 为什么呢?《Java并发编程实战》这么说:计算(CPU)密集型的线程恰好在某时因为发生一个页错误或者因其他原因而暂停,刚好有一个“额外”的线程,可以确保在这种情况下CPU周期不会中断工作。

I/O密集型程序创建多少个线程合适?

最佳线程数 = CPU核心数 * (1/CPU利用率) = CPU核心数 * (1 + (I/O耗时/CPU耗时))

并发编程模型

并发编程模型主要是为了解决上面我们提到的并发三大问题: 可见性/原子性/有序性

在并发编程中,主要解决的问题就是线程之间的如何实现通信,通信的机制主要有两种:共享内存通信消息传递

  • 线程与锁: 有不足,但是仍然是其他模型的技术基础。
  • 函数式编程:函数式编程对并发编程和并行编程提供了良好的支持。函数式编程消除了可变状态,所以从根本上是线程安全的,且易于并行执行。
  • Clojure之道--分离标识与状态:clojure是一种指令式编程和函数式编程混搭的方案,在两种编程方式上取得微妙的平衡来发挥两种优势。
  • actor: actor模型是一种适用性很广的并发编程模型,适用于共享内存模型和分布式内存模型,也适合解决地理分布型问题,能提供强大的容错性。
  • 通信顺序进程(Communicating Sequential Processes, CSP): 表面上看,CSP模型与actor模型很相似,两者都基于消息传递。不过CSP模型侧重于传递信息的通道,而actor模型侧重于通道两端的实体,使用CSP模型的代码会带有明显不同的风格。
  • 数据级并行:每台电脑中都藏着一台超级计算机--GPU。GPU利用数据级并行,可以加快图像处理,也可以用于更广阔的领域。例如 有限元分析、流体力学计算 或其他的大量数字计算。
  • Lambda架构:Lambda架构综合了MapReduce和流式处理的特点,是一种能够处理多种大数据问题的架构。

这里沿用了"七周七并发模型"的内容, 这本书暂时没看, 等看了之后再补充~

CPU中超线程对多线程编程的影响

如今CPU的6核12线程, 其中的12线程就是应用了超线程技术. 超线程的目的是为了更充分地利用一个单核CPU的资源。CPU在执行一条机器指令时,并不会完全地利用所有的CPU资源,而且实际上,是有大量资源被闲置着的。超线程技术允许两个线程同时不冲突地使用CPU中的资源。

例如: 一条整数运算指令只会用到整数运算单元,此时浮点运算单元就空闲了,若使用了超线程技术,且另一个线程刚好此时要执行一个浮点运算指令,CPU就允许属于两个不同线程的整数运算指令和浮点运算指令同时执行.

当前这并不能认为一直都可以并行执行. 实际上超线程可能是并行的,也可能不是并行的. 当两条指令都要应用整数计算单元时就不能并执行了.

与此相关的CPU技术还有超标量技术, 试图在一个周期取出多条指令并行执行,是通过内置多条流水线来同时执行多个处理,其实质是以空间换取时间。但由于指令之间的相关性,即后一条指令需要前一条指令的结果,超标量CPU的性能是一个周期能执行1.2条指令,而为了取得这20%的性能改善,超标量CPU需要增加大量的硬件电路来调度这些同时取出的指令,比如寄存器重命名,预约站,重排序缓冲区等. 它提高的是单线程程序的执行效率. 这种对于程序员是无感知的, 有解释器优化代码执行顺序.

其实超线程的效率提升不高,逻辑线程提供的能力没有真正线程来的强。因为单个物理线程上使用指令级并行后,能够同时进行计算的ALU单元已经被利用的差不多了,固定时间内剩下的ALU计算剩余资源不多,这部分剩余给超线程其实提升不了很大的性能。

扩展阅读

Golang、Kotlin、C#、JS、Python等都有协程,市面上的协程有什么本质上的区别?
Python协程是什么?有什么用?
问一个协程方面的问题
Python 协程详解
Go 与 C# 对比 第一篇:Goroutines 与 Async-Await
编程语言演变对开发者的意义
《七周七并发模型》笔记
实现Actor模式,参考 Skynet 设计的c#服务器
不一样的epoll讲解,深入理解epoll背后的原理
I/O 多路复用深入理解
全网最详细的 I/O 多路复用解析

此处评论已关闭