关于高并发的几个基础问题

 

什么是C10K?

C10K 就是 Client 10000 问题,即

在同时连接到服务器的客户端数量超过 10000 个的环境中,即便硬件性能足够, 依然无法正常提供服务。”,

简而言之,就是单机1万个并发连接问题。

这个概念最早由 Dan Kegel 提出并发布于其个人站点

解决方案就是IO多路复用机制(select、poll、epoll等)。

最弱连接(Weakest link)

如果往两端用力拉一条由很多环 (连接)组成的锁链,其中最脆弱的一个连接会先断掉。

因此,锁链整体的强度取决于其中最脆弱的一环。

select和epoll模型的区别是什么?

1. 不同点一:文件描述符限制
select单个进程能够监视的文件描述符的数量存在最大限制。
epoll没有文件描述符限制。

2. 不同点二:监听方式
select调用会阻塞,直到有描述符就绪(有数据可读、可写、或者有except),或者超时(timeout指定等待时间),函数返回。
当select函数返回后,需要通过遍历fdset,才能找到就绪的描述符。
epoll事先通过epoll_ctl()来注册一个文件描述符,一旦某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait() 时便得到通知。
即此处去掉了遍历文件描述符,而是通过监听回调的的机制。通过每个fd定义的回调函数来实现的。只有就绪的fd才会执行回调函数。

3. 相同点一:实现机制
select和epoll都是IO多路复用的机制。
I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。

4. 相同点二:同步I/O
select和epoll都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的。
而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。

水平触发(level triggered)和边缘触发(edge triggered)

LT模式
当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件;下次调用epoll_wait时,会再次响应应用程序并通知此事件。
关注点是数据(读操作缓冲区不为空,写操作缓冲区不为满),epoll_wait 总会返回就绪。
LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket。
在这种模式中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。
如果你不作任何操作,内核还是会继续通知你的。


ET模式
当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件;如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。
关注点是变化,只有监视的文件上有数据变化发生(读操作关注有数据写进缓冲区,写操作关注数据从缓冲区取走),epoll_wait 才会返回。

ET(edge-triggered)是高速工作方式,只支持no-block socket。
在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。
然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,
直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求。
但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)。

ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。
epoll工作在ET模式的时候,必须使用非阻塞套接字接口,
以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

协程解决的是什么问题?

在高并发场景上,协程解决了c10k的问题(c10k的一个表现就是系统CPU高,因为操作系统要调度工作线程)。
这是由于IO的使用方式是一个连接fd对应一个线程,fd采用阻塞方式调用,当fd不可读、写时,线程不可调度。
当连接过高时,线程数量也大量增长,线程不仅占用了大量内存,
而且调度线程也需要大量的cpu,所以并发到10k的时候就达到瓶颈。

操作系统为解决这种问题,提供了多路复用的接口。
一个线程可以处理多个连接,当一个连接不可读写时,线程不会阻塞,线程检查是否有其他可读写的连接;
这样操作系统节省了大部分内存和线程调度所需要的的cpu,基于这种技术单机并发可以达到百万甚至更高。

这个工作方式解决了并发的问题,但是这种方式操作复杂,当连接不可用时,应用程序需要保存连接的上下文,待连接可用时在继续之前的操作。

协程解决了这种问题,协程内部帮应用程序保存了连接的上下文,
开发者不用关心IO多路复用的实现,可以认为IO操作是阻塞的调用,极大方便的开发者;
比如openresty就是在lua层面实现了协程,它不仅可以保证高并发,对开发者而言编程也特别简单。

golang协程及其调度

golang不仅支持IO的协程处理,还提供了事件、管道等阻塞调用的组件,对于使用者是阻塞的,

对于程序而言只是不再处理当前的逻辑,转而去执行其他可执行的逻辑,将cpu利用率最大化,线程调度最小化。

golang抽象了P、M、G三种对象实现了协程的调度

G(goroutine)是协程(用户线程),执行应用程序逻辑代码,数量动态增减。主要有以下几种状态:

  - 初始

  - 待运行(G处在运行队列中,等待M取出并运行)

  - 运行中

  - 等待(G在等待某些条件完成,比如执行了一个不可读的channel,这时G既不在运行中也不再运行队列中)

  - 系统调用(M正在运行这个G发起的系统调用)

  - 已终止。

P(process)是逻辑cpu,就是计算资源,在程序启动时创建,P的数量默认等于cpu核心数, 但可以通过环境变量GOMAXPROC修改,配置后不可变更,主要有两种状态空闲、运行。

M(machine)是内核线程,用于在P上调度G,数量动态增加,只增不减,主要有以下几种状态:

  - 自旋(即M正在从运行队列中获取G,这时M拥有一个P)

  - 运行G

  - 等待(找不到可运行的G,就要从自旋变成等待状态,这时M并不拥有P;因为自旋也是占用CPU的,等待就让出CPU了;如果之后有可运行的时,可以通过futex去唤醒等待中的M去执行)

  - 系统调用(阻塞状态)

G的调度就是M调度G在P上运行,让最少的M将P的利用率最大化。

M=P是最完美的状态(openresty),但是当M由于系统调用变成不可用时(阻塞),P不能被利用,如果有待运行的G时,就要考虑新建M运行待运行的G。

M是工作线程,用最少的M在P上运行G,这是golang设计的目标,因为M多了,操作系统要调度M。

golang的GMP原理

GMP模型

- Go需要保证有足够的M可以运行G, 不让CPU闲着, 也需要保证M的数量不能过多,避免过度的CPU调度消耗。

- M(内核线程)是运行goroutine的实体,goroutine调度器的功能是把可运行的goroutine分配到内核线程(即工作线程)上。

- Goroutine调度器和OS调度器是通过M结合起来的,每个M都代表了1个内核线程,OS调度器负责把内核线程分配到物理CPU的核上执行。

- P可以理解为控制go代码并行度的机制。

go func()调度流程

 

 

参考:Linux IO模式及 select、poll、epoll详解

参考:Golang调度器GMP原理与调度全分析

posted @ 2021-05-30 14:29  青山应回首  阅读(139)  评论(0编辑  收藏  举报