maksim 发布的文章

我们以使用Docker的方式安装MySQL为例,快速体会Docker的基本操作

镜像操作

下载镜像 docker pull mysql

#我们应该先去Docker Hub寻找我们感兴趣的镜像
docker pull image_name[:Tag]
#1、tag一般为镜像的版本,不指定默认下载latest版本
#2、默认镜像是从Docker Hub下载,国内比较慢,推荐使用自己的阿里云加速
sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json <<-'EOF'
{
  "registry-mirrors": ["https://82m9ar63.mirror.aliyuncs.com"]
}
EOF
sudo systemctl daemon-reload
sudo systemctl restart docker

l8u4jp1z.png

  • 以上下载过程看出,镜像是分层的(Layer)。c499e6d256d6是当前层的唯一id(完整的id为256bit,64个十六进制字符组成)。
  • 不同的镜像如果有相同的层,本地只会存储一份,减小了存储空间
  • 严格说,下载镜像需要制定指定仓库名称,不过从Docker Hub下载的可以忽略前缀。如

    • docker pull docker.io/library/mysql:5.7

列出镜像 docker images

docker images

l8u4k2c4.png

  • REPOSITORY:来源于哪个仓库
  • TAG:镜像版本标签信息
  • IMAGE ID :镜像唯一id
  • CREATED:镜像最后更新时间
  • SIZE:镜像大小

查看详情 docker inspect

docker inspect image_name[:tag]
docker inspect image_id  

删除镜像 docker rmi

docker rmi image_name[:tag] 或者 docker rmi image_id

理解镜像

1、疑问一:

Docker每一个容器都是一个完整的这个应用的运行环境。所有应用最起码的基本环境是linux

SB微服务做成镜像。基本环境就是java环境,

如果我下载了100个镜像,启动了100个容器。100个linux在运行?

UFS:特点;git。增量记录文件改变。分层系统。需要经常读写变化的,只在变化层。

容器操作

启动容器

#docker run [OPTIONS] IMAGE [COMMAND] [ARG...] 常用选项
-d  守护态运行
-p  Publish a container's port(s) to the host ( container和host端口映射)
-i  以交互模式运行容器,通常与 -t 同时使用
-t  为容器重新分配一个伪输入终端,通常与 -i 同时使用
--name="nginx-lb"  为容器指定一个名称
-v, --volume value  Bind mount a volume (default [])
docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=123456 --name hello-mysql mysql:5.7

#查看所有运行中的容器
docker ps 
#查看所有容器
docker ps -a
#docker create 和 docker run的区别
docker create -p 3306:3306 -e MYSQL_ROOT_PASSWORD=123456 --name hello-mysql mysql:5.7
#create新建容器,run新建并启动。create相当于 run -d设置

进入容器

#docker exec  [OPTIONS] CONTAINER COMMAND [ARG...]
#Run a command in a running container
docker exec -it hello-mysql /bin/bash    
#尝试连接MySQL
mysql -uroot -p123456
#扩展
docker exec -d hello-mysql touch /hello.txt
docker exec -it hello-mysql mysql -uroot -p

#删除
docker rm container_id/container_name
#查看运行日志
docker logs container_id/container_name

其他命令

Docker 命令手册

可视化

  • Portainer(先用这个)
docker run -d -p 8088:9000 \
--restart=always -v /var/run/docker.sock:/var/run/docker.sock --privileged=true portainer/portainer
  • Rancher(CI/CD再用这个)
#安装rancher-server
docker run --name rancher-server -p 8000:8080 -v /etc/localtime:/etc/localtime:ro  -d  rancher/server
#安装agent
docker run --rm --privileged -v /var/run/docker.sock:/var/run/docker.sock -v /var/lib/rancher:/var/lib/rancher rancher/agent:v1.2.11 http://39.101.191.131:8000/v1/scripts/D3DBD43F263109BB881F:1577750400000:7M0yBzCw4XSxJklD7TpysYIpI

为什么是Docker?

l8u4gvqk.png

Docker 是一个开源的应用容器引擎,基于 Go 语言 并遵从 Apache2.0 协议开源。

Docker解决了什么问题

  • 快速交付和部署(镜像与容器)
  • 资源的高效利用和隔离(高密度部署)
  • 轻松的迁移和扩展(一次封装,到处运行)

l8u4hhsd.png

Docker使用步骤

  • 安装Docker(安装应用商店)
  • 寻找/下载镜像(寻找/下载app)
  • 启动容器(启动应用)
  • 移植(别的手机安装应用商店,继续以前流程)

容器化对比虚拟机

对比属性ContainerVM
隔离性基于进程隔离提供资源的完全隔离
启动时间秒级分钟级
内核共用宿主机内核使用独立内核
占用资源MB级GB级
系统支持容量单机支持上千个容器一般几十个

核心概念

l8u4irdr.png

Docker 镜像

Docker镜像类似于虚拟机镜像,可以将他理解为一个只读的模板。

Docker 容器

Docker容器类似于一个轻量级的沙箱,Docker利用容器来运行和隔离应用。容器是从镜像创建来的。容器可以启动、停止、删除,容器之间彼此隔离,互不可见。

可以把容器看做是一个简易版的Linux系统环境(包括root用户权限、进程空间、用户空间和网络空间等)以及运行在其中的应用程序打包而成的盒子

Docker 仓库

Docker仓库是存储镜像的仓库。可以有Public(公有仓库)和Private(私有仓库)。最大的公有仓库是Docker Hub

Docker面向对象
镜像
容器对象

安装启动

阿里云安装

yum install docker
systemctl enable docker
systemctl start docker

docker -v
docker info

标准方式安装

#移除旧版本
yum remove docker \
                  docker-client \
                  docker-client-latest \
                  docker-common \
                  docker-latest \
                  docker-latest-logrotate \
                  docker-logrotate \
                  docker-engine
#安装Docker其他依赖
yum install -y yum-utils \
  device-mapper-persistent-data \
  lvm2
#设置Docker yum源
yum-config-manager \
    --add-repo \
    https://download.docker.com/linux/centos/docker-ce.repo
#安装Docker CE
yum install -y docker-ce docker-ce-cli containerd.io

#安装指定版本的Docker
yum list docker-ce --showduplicates | sort -r
yum install docker-ce-<VERSION_STRING> docker-ce-cli-<VERSION_STRING> containerd.io
#启动Docker
systemctl enable docker
systemctl start docker

这篇文章来源于我在团队内部分享的内容,PPT 和代码已经上传到了 github。

better-maksim/swoole-rate-limiting: 基于 swoole 实现限流算法 (github.com)

  • 什么是限流
  • 限流的实现原理
  • 优缺点分析

什么是限流?

  • 通过并发限速(拒绝[降级]、排队、等待)形式达到保护服务的目的。
  • 一部分人能访问,好过所有人都无法访问。
  • 如何做:统计当前并发情况,当并发量达到预设峰值时拒绝访问。

生活中的限流场景

  1. 布达拉宫需要提前几个月进行预订
  2. 海底捞在满座后需要进行等待
  3. 魔兽世界需要排队登录
  4. 12306 如果使用刷票软件的话会被暂时禁止访问,还有就是限制时段购票

常见的限流算法

  • 计数器
  • 滑动窗口
  • 漏铜
  • 令牌

计数器算法

计数器固定窗口算法是最基础也是最简单的一种限流算法。原理就是对一段固定时间窗口内的请求进行计数,如果请求数超过了阈值,则舍弃该请求;如果没有达到设定的阈值,则接受该请求,且计数加1。当时间窗口结束时,重置计数器为0。

2022-09-29T16:27:51.png

计数器固定窗口算法原理图

特点分析

优点:实现简单,容易理解。

缺点:流量曲线可能不够平滑,有“突刺现象”,如下图所示。这样会有两个问题:

2022-09-29T16:28:43.png

计数器固定窗口算法限流曲线

一段时间内(不超过时间窗口)系统服务不可用。比如窗口大小为1s,限流大小为100,然后恰好在某个窗口的第1ms来了100个请求,然后第2ms-999ms的请求就都会被拒绝,这段时间用户会感觉系统服务不可用。

窗口切换时可能会产生两倍于阈值流量的请求。比如窗口大小为1s,限流大小为100,然后恰好在某个窗口的第999ms来了100个请求,窗口前期没有请求,所以这100个请求都会通过。再恰好,下一个窗口的第1ms有来了100个请求,也全部通过了,那也就是在2ms之内通过了200个请求,而我们设定的阈值是100,通过的请求达到了阈值的两倍。

2022-09-29T16:29:58.png

计数器固定窗口限流算法产生两倍于阈值流量的请求

计数器滑动窗口算法

计数器滑动窗口算法是计数器固定窗口算法的改进,解决了固定窗口切换时可能会产生两倍于阈值流量请求的缺点。

滑动窗口算法在固定窗口的基础上,将一个计时窗口分成了若干个小窗口,然后每个小窗口维护一个独立的计数器。当请求的时间大于当前窗口的最大时间时,则将计时窗口向前平移一个小窗口。平移时,将第一个小窗口的数据丢弃,然后将第二个小窗口设置为第一个小窗口,同时在最后面新增一个小窗口,将新的请求放在新增的小窗口中。同时要保证整个窗口中所有小窗口的请求数目之后不能超过设定的阈值。

2022-09-29T16:31:59.png

从图中不难看出,滑动窗口算法就是固定窗口的升级版。将计时窗口划分成一个小窗口,滑动窗口算法就退化成了固定窗口算法。而滑动窗口算法其实就是对请求数进行了更细粒度的限流,窗口划分的越多,则限流越精准。

特点分析

  1. 避免了计数器固定窗口算法固定窗口切换时可能会产生两倍于阈值流量请求的问题;
  2. 和漏斗算法相比,新来的请求也能够被处理到,避免了漏斗算法的饥饿问题。

漏斗算法

漏斗算法的原理也很容易理解。请求来了之后会首先进到漏斗里,然后漏斗以恒定的速率将请求流出进行处理,从而起到平滑流量的作用。当请求的流量过大时,漏斗达到最大容量时会溢出,此时请求被丢弃。从系统的角度来看,我们不知道什么时候会有请求来,也不知道请求会以多大的速率来,这就给系统的安全性埋下了隐患。但是如果加了一层漏斗算法限流之后,就能够保证请求以恒定的速率流出。在系统看来,请求永远是以平滑的传输速率过来,从而起到了保护系统的作用。

2022-09-29T16:32:53.png

漏斗算法原理图

令牌桶算法是对漏斗算法的一种改进,除了能够起到限流的作用外,还允许一定程度的流量突发。在令牌桶算法中,存在一个令牌桶,算法中存在一种机制以恒定的速率向令牌桶中放入令牌。令牌桶也有一定的容量,如果满了令牌就无法放进去了。当请求来时,会首先到令牌桶中去拿令牌,如果拿到了令牌,则该请求会被处理,并消耗掉拿到的令牌;如果令牌桶为空,则该请求会被丢弃。

特点分析

  1. 漏桶的漏出速率是固定的,可以起到整流的作用。即虽然请求的流量可能具有随机性,忽大忽小,但是经过漏斗算法之后,变成了有固定速率的稳定流量,从而对下游的系统起到保护作用。
  2. 不能解决流量突发的问题。还是拿刚刚测试的例子,我们设定的漏斗速率是2个/秒,然后突然来了10个请求,受限于漏斗的容量,只有5个请求被接受,另外5个被拒绝。你可能会说,漏斗速率是2个/秒,然后瞬间接受了5个请求,这不就解决了流量突发的问题吗?不,这5个请求只是被接受了,但是没有马上被处理,处理的速度仍然是我们设定的2个/秒,所以没有解决流量突发的问题。而接下来我们要谈的令牌桶算法能够在一定程度上解决流量突发的问题,读者可以对比一下。

令牌桶算法

2022-09-29T16:34:17.png

令牌桶算法是对漏斗算法的一种改进,除了能够起到限流的作用外,还允许一定程度的流量突发。在令牌桶算法中,存在一个令牌桶,算法中存在一种机制以恒定的速率向令牌桶中放入令牌。令牌桶也有一定的容量,如果满了令牌就无法放进去了。当请求来时,会首先到令牌桶中去拿令牌,如果拿到了令牌,则该请求会被处理,并消耗掉拿到的令牌;如果令牌桶为空,则该请求会被丢弃。

特点分析

令牌桶算法是对漏桶算法的一种改进,除了能够在限制调用的平均速率的同时还允许一定程度的流量突发。

总结

计数器固定窗口算法实现简单,容易理解。和漏斗算法相比,新来的请求也能够被马上处理到。但是流量曲线可能不够平滑,有“突刺现象”,在窗口切换时可能会产生两倍于阈值流量的请求。而计数器滑动窗口算法作为计数器固定窗口算法的一种改进,有效解决了窗口切换时可能会产生两倍于阈值流量请求的问题。

漏斗算法能够对流量起到整流的作用,让随机不稳定的流量以固定的速率流出,但是不能解决流量突发的问题。令牌桶算法作为漏斗算法的一种改进,除了能够起到平滑流量的作用,还允许一定程度的流量突发。

以上四种限流算法都有自身的特点,具体使用时还是要结合自身的场景进行选取,没有最好的算法,只有最合适的算法。

比如令牌桶算法一般用于保护自身的系统,对调用者进行限流,保护自身的系统不被突发的流量打垮。如果自身的系统实际的处理能力强于配置的流量限制时,可以允许一定程度的流量突发,使得实际的处理速率高于配置的速率,充分利用系统资源。而漏斗算法一般用于保护第三方的系统,比如自身的系统需要调用第三方的接口,为了保护第三方的系统不被自身的调用打垮,便可以通过漏斗算法进行限流,保证自身的流量平稳的打到第三方的接口上。

算法是死的,而算法中的思想精髓才是值得我们学习的。实际的场景中完全可以灵活运用,还是那句话,没有最好的算法,只有最合适的算法。

这篇文章转载自好未来学而思技术团队,分享老师:李乐,本文基于Swoole-4.3.2和PHP-7.1.0版本

Swoole协程简介

Swoole4为PHP语言提供了强大的CSP协程编程模式,用户可以通过go函数创建一个协程,以达到并发执行的效果,如下面代码所示:

//Co::sleep() 是 Swoole 提供的 API,并不会阻塞当前进程,只会阻塞协程触发协程切换
go(function() {
    Co::sleep(1);
    echo "a";
});

go(function() {
    Co::sleep(2);
    echo "b";
});

echo "c";

//输出结果:cab
//程序总执行时间 2 秒

其实在Swoole4之前就实现了多协程编程模式,在协程创建、切换以及结束的时候,相应的操作php栈即可(创建、切换以及回收php栈)。

此时的协程实现无法完美的支持php语法,其根本原因在于没有保存c栈信息。(vm内部或者某些扩展提供的API是通过c函数实现的,调用这些函数时如果发生协程切换,c栈该如何处理?)

Swoole4新增了c栈的管理,在协程创建、切换以及结束的同时会伴随着c栈的创建、切换以及回收。

Swoole4协程实现方案如下图所示:

2023-03-25T15:37:46.png

其中:

  • API层是提供给用户使用的协程相关函数,比如go()函数用于创建协程;Co::yield()使得当前协程让出CPU;Co::resume()可恢复某个协程执行;
  • Swoole4协程需要同时管理c栈与php栈,Coroutine用于管理c栈,PHPCoroutine用于管理php栈;其中Coroutine(),yield(),resume()实现了c栈的创建以及换入换出;create_func(),on_yield(),on_resume()实现了php栈的创建以及换入换出;
  • Swoole4在管理c栈时,用到了boost.context库,make_fcontext()和jump_fcontext()函数均使用汇编语言编写,实现了c栈上下文的创建以及切换;
  • Swoole4对boost.context进行了简单封装,即Context层,Context(),SwapIn()以及SwapOut()对应c栈的创建以及换入换出。

深入理解C栈

函数是对代码的封装,对外暴露的只是一组指定的参数和一个可选的返回值;假设函数P调用函数Q,Q执行后返回函数P,实现该函数调用需要考虑以下三点:

  • 指令跳转:进入函数Q的时候,程序计数器必须被设置为Q的代码的起始地址;在返回时,程序计数器需要设置为P中调用Q后面那条指令的地址;
  • 数据传递:P能够向Q提供一个或多个参数,Q能够向P返回一个值;
  • 内存分配与释放:Q开始执行时,可能需要为局部变量分配内存空间,而在返回前,又需要释放这些内存空间;

大多数语言的函数调用都采用了栈结构实现,函数的调用与返回即对应的是一系列的入栈与出栈操作,我们通常称之为函数栈帧(stack frame)。示意图如下:

2023-03-25T15:38:42.png

上面提到的程序计数器即寄存器%rip,另外还有两个寄存器需要重点关注:%rbp指向栈帧底部,%rsp指向栈帧顶部。

下面将通过具体的代码事例,为读者讲解函数栈帧。c代码与汇编代码如下:

2023-03-25T15:39:14.png

2023-03-25T15:39:25.png

分析汇编代码:

  • main函数与add函数入口,首先将寄存器%rbp压入栈中用于保存其值,其次移动%rbp指向当前栈顶部(此时%rbp,%rsp都指向栈顶,开始新的函数栈帧);
  • main函数"subq $16, %rsp",是为main函数栈帧预留16个字节的内存空间;
  • 调用add函数时,第一个参数和第二个参数分别保存在寄存器%edi和%esi,返回值保存在寄存器%eax;
  • call指令用于函数调用,实现了两个功能:寄存器%rip压入栈中,跳转到新的代码位置;
  • ret指令用于函数返回,弹出栈顶内容到寄存器%rip,依次实现代码跳转;
  • leave指令等同于两条指令:movq %rsp,%rbp和popq %rbp,用于释放main函数栈帧,恢复前一个函数栈帧;
  • 注意add函数栈帧,并没有为其预留空间,寄存器%rsp和%rbp都指向栈帧底部;根本原因是add函数没有调用其他函数。

该程序的栈结构示意图如下:

2023-03-25T15:40:05.png

问题:观察上面的汇编代码,输入参数分别使用的是寄存器%edi和%esi,返回值使用的是寄存器%eax,输入输出参数不应该保存在栈上吗?寄存器比内存访问要快的多,现代处理器寄存器数目也比较多,因此倾向于将参数优先保存在寄存器。比如%rdi, %rsi, %rdx, %rcx, %r8d, %r9d 六个寄存器用于存储函数调用时的前6个参数,那么当输入参数数目超过6个时,如何处理?这些输入参数只能存储在栈上了。
(%rdi等表示64位寄存器,%edi等表示32位寄存器)

2023-03-25T15:40:26.png

Swoole C栈管理

通过学习c栈基本知识,我们知道最主要有三个寄存器:%rip程序计数器指向下一条需要执行的指令,%rbp指向函数栈帧底部,%rsp指向函数栈帧顶部。这三个寄存器可以确定一个c栈执行上下文,c栈的管理其实就是这些寄存器的管理。

第一节我们提到Swoole在管理c栈时,用到了boost.context库,其中make_fcontext()和jump_fcontext()函数均使用汇编语言编写,实现了c栈执行上下文的创建以及切换;函声明命如下:

fcontext_t make_fcontext(void *sp, size_t size, void (*fn)(intptr_t));
intptr_t jump_fcontext(fcontext_t *ofc, fcontext_t nfc, intptr_t vp, bool preserve_fpu = false);

make_fcontext函数用于创建一个执行上下文,其中参数sp指向内存最高地址处(在堆中分配一块内存作为该执行上下文的c栈),参数size为栈大小,参数fn是一个函数指针,指向该执行上下文的入口函数;代码主要逻辑如下:

2023-03-25T15:40:54.png

make_fcontext函数创建的执行上下文示意图如下(可以看到预留了若干字节用于保存上下文信息):

2023-03-25T15:42:12.png

Swoole协程实现的Context层封装了上下文的创建,创建上下文函数实现如下:

2023-03-25T15:42:27.png

可以看到c栈执行上下文是通过sw_malloc函数在堆上分配的一块内存,默认大小为2M字节;参数sp指向的是内存最高地址处;执行上下文的入口函数为Context::context_func()。

jump_fcontext函数用于切换c栈上下文:

  1. 函数会将当前上下文(寄存器)保存在当前栈顶(push),同时将%rsp寄存器内容保存在ofc地址;
  2. 函数从nfc地址处恢复%rsp寄存器内容,同时从栈顶恢复上下文信息(pop)。

代码主要逻辑如下:

2023-03-25T15:43:50.png

观察jump_fcontext函数的汇编代码,可以看到保存上下文与恢复上下文的代码基本是对称的。恢复上下文时"popq %r8"用于弹出上一次保存的程序计数器%rip的内容,然而并没有看到保存寄存器%rip的代码。这是因为调用jump_fcontext函数时,底层call指令已经将%rip入栈了。

Swoole协程实现的Context层封装了上下文的换入换出,可以在上下文swap_ctx_和ctx_之间随时换入换出,代码实现如下:

2023-03-25T15:44:28.png

上下文示意图如下所示:

2023-03-25T15:45:07.png

Swoole PHP栈管理

php代码在执行时,同样存在函数栈帧的分配与回收。php将此抽象为两个结构,php栈zend_vm_stack,与执行数据(函数栈帧)zend_execute_data。

php栈结构与c栈结构基本类似,定义如下:

struct _zend_vm_stack {
    zval *top;
    zval *end;
    zend_vm_stack_prev;
}

其中top字段指向栈顶位置,end字段指向栈底位置;prev指向上一个栈,形成链表,当栈空间不够时,可以进行扩容。php虚拟机申请栈空间时默认大小为256K,Swoole创建栈空间时默认大小为8K。

执行数据结构体,我们需要重点关注这几个字段:当前函数编译后的指令集(opline指向指令集数组中的某一个元素,虚拟机只需要遍历该数组并执行所有指令即可),函数返回值,以及调用该函数的执行数据;结构定义如下:

2023-03-25T15:46:47.png

php栈初始化函数为zend_vm_stack_init;当执行用户函数调用时,虚拟机通过函数zend_vm_stack_push_call_frame在php栈上分配新的执行数据,并执行该函数代码;函数执行完成后,释放该执行数据。代码逻辑如下:

2023-03-25T15:46:59.png

php栈帧结构示意图如下:

2023-03-25T15:47:25.png

Swoole协程实现,需要自己管理php栈,在发生协程创建以及切换时,对应的创建新的php栈,切换php栈,同时保存和恢复php栈上下文信息。这里涉及到一个很重要的结构体php_coro_task:

2023-03-25T15:47:40.png

这里列出了php_coro_task结构体的若干关键字段,这些字段用于保存和恢复php上下文信息。

协程创建时,底层通过函数PHPCoroutine::create_func实现了php栈的创建:

2023-03-25T15:53:51.png

从代码中可以看到结构php_coro_task是直接存储在php栈的底部。

当通过yield函数让出CPU时,底层会调用函数 PHPCoroutine::on_yield 切换 php 栈:

2023-03-25T15:54:19.png

Swoole协程实现

前面我们简单介绍了Swoole协程的实现方案,以及Swoole对c栈与php栈的管理,接下来将结合前面的知识,系统性的介绍Swoole协程的实现原理。

Swoole协程数据模型

话不多说,先看一张图:

2023-03-25T15:54:43.png

  • 每个协程都需要管理自己的c栈与php栈;
  • Context封装了c栈的管理操作;ctx_字段保存的是寄存器%rsp的内容(指向c栈栈顶位置);swap_ctx_字段保存的是将被换出的协程寄存器%rsp内容(即,将被换出的协程的c栈栈顶位置);SwapIn()对应协程换入操作;SwapOut()对应协程换出操作;
  • 参考jump_fcontext实现,协程在换出时,会将寄存器%rip,%rbp等暂存在c栈栈顶;协程在换入时,相应的会从栈顶恢复这些寄存器的内容;
  • Coroutine管理着协程所有内容;cid字段表示当前协程的ID;task字段指向当前协程的php_coro_task结构,该结构中保存的是当前协程的php栈信息(vm_stack_top,execute_data等);ctx字段指向的是当前协程的Context对象;origin字段指向的是另一个协程Coroutine对象;yield()和resume()对应的是协程的换出换入操作;
  • 注意到php_coro_task结构的co字段指向其对应的协程对象Coroutine;
  • Coroutine还有一些静态属性,静态属性的属于类属性,所有协程共享的;last_cid字段存储的是当前最大的协程ID,创建协程时可用于生成协程ID;current字段指向的是当前正在运行的协程Coroutine对象;on_yield和on_resume是两个函数指针,用于实现php栈的切换操作,实际指向的是方法PHPCoroutine::on_yield和PHPCoroutine::on_resume;

Swoole协程创建与切换

协程创建

Swoole创建协程可以使用go()函数,底层实现对应的是PHP_FUNCTION(swoole_coroutine_create),其函数实现如下:

2023-03-25T15:55:43.png

  • 注意Coroutine::create函数第一个参数伟create_func,该函数后续用于创建php栈,并开始协程代码的执行;
  • 可以看到PHPCoroutine::create在调用Coroutine::create创建创建协程之前,保存了当前php栈信息到php_coro_task结构中。
  • 注意主程序的php栈是虚拟机创建的,结构与上面画的协程php栈不同,主程序的php_coro_task结构并没有存储在php栈上,而是一个静态变量PHPCoroutine::main_task,从get_task方法可以看出,主程序中get_current_task()返回的是null,因此最后获得的php_coro_task结构是PHPCoroutine::main_task。

2023-03-25T15:56:09.png

  • 在Coroutine的构造函数中完成了协程对象Coroutine的创建与初始化,以及Context对象的创建与初始化(创建了c栈);run()函数执行了协程的换入,从而开始协程的运行;

2023-03-25T15:56:28.png

  • 可以看到创建协程对象Coroutine时,通过last_cid来计算当前协程的ID,同时将该协程对象加入到全局map中;代码ctx(stack_size,fn, private_data)创建并初始化了Context对象;
  • run()函数将该协程换入执行时,赋值origin为当前协程(主程序中current为null),同时设置current为当前协程对象Coroutine;调用SwapIn()函数完成协程的换入执行;最后如果协程执行完毕,则关闭并释放该协程对象Coroutine;
  • 初始化Context对象时,可以看到其构造函数Context::Context(size_tstack_size, coroutine_func_t fn, void* private_data),其中参数fn为协程入口函数(PHPCoroutine::create_func),可以看到其赋值给ontext对象的字段fn_,但是在创建c栈上下文时,其传入的入口函数为context_func;

2023-03-25T15:57:07.png

  • 函数context_func内部其实调用的就是方法PHPCoroutine::create_func;当协程执行结束时,会标记end字段为true,同时将该协程换出;

2023-03-25T15:57:28.png

问题:参数arg为什么是Context对象呢,是如何传递的呢?这就涉及到jump_fcontext汇编实现,以及jump_fcontext的调用了

2023-03-25T15:57:47.png

调用jump_fcontext函数时,第三个参数传递的是this,即当前Context对象;而函数jump_fcontext汇编实现时,将第三个参数的内容拷贝到%rdi寄存器中,当协程换入执行函数context_func时,寄存器%rdi存储的就是第一个参数,即Context对象。

方法PHPCoroutine::create_func就是创建并初始化php栈,执行协程代码;这里不做过多介绍。

问题:Coroutine的静态属性on_yield和on_resume时什么时候赋值的?

在Swoole模块初始化时,会调用函数swoole_coroutine_util_init(该函数同时声明了"Co"等短名称),该函数进一步的调用PHPCoroutine::init()方法,该方法完成了静态属性的赋值操作。

2023-03-25T15:59:00.png

协程切换

用户可以通过Co::yield()和Co::resume()实现协程的让出和恢复,Co::yield()的底层实现函数为PHP_METHOD(swoole_coroutine_util, yield),Co::resume()的底层实现函数为PHP_METHOD(swoole_coroutine_util, resume)。本节将为读者讲述协程切换的实现原理。

2023-03-25T15:59:18.png

  • 调用Co::resume()恢复某个协程之前,该协程必然已经调用Co::yield()让出CPU;因此在Co::yield()时,会将该协程对象添加到全局map中;Co::resume()时做相应校验,如果校验通过则恢复协程,并从map种删除该协程对象;
  • co->yield()实现了协程的让出操作;

    1. 设置协程状态为SW_CORO_WAITING;
    2. 回调on_yield方法,即PHPCoroutine::on_yield,保存当前协程(task代表协程)的php栈上下文,恢复另一个协程的php栈上下文(origin代表另一个协程对象);
    3. 设置当前协程对象为origin;
    4. 换出该协程;

2023-03-25T15:59:46.png

  • co->resume()实现了协程的恢复操作:

    1. 设置协程状态为SW_CORO_RUNNING;
    2. 回调on_resume方法,即PHPCoroutine::on_resume,保存当前协程(current协程)的php栈上下文,恢复另一个协程(task代表协程)的php栈上下文;
    3. 设置origin为当前协程对象,current为即将要换入的协程对象;
    4. 换入协程;

2023-03-25T16:00:41.png

Swoole协程有四种状态:初始化,运行中,等待运行,运行结束;定义如下:

2023-03-25T16:01:00.png

协程之间可以通过Coroutine对象的origin字段形成一个类似链表的结构;
Co::yield()换出当前协程时,会换入origin协程;
在A协程种调用Co::resume()恢复B协程时,会换出A协程,换入B协程,同时标记A协程为B的origin协程;

协程切换过程比较简单,这里不做过多详述。

Swoole协程调度

Swoole的协程调度是基于事件驱动的,下面将介绍socket读写事件以及sleep定时器事件触发的协程调度。

socket读写事件

Swoole的socket读写使用的成熟的IO多路复用模型:epoll/kqueue/select/poll等,并且将其封装在结构体_swReactor中,其定义如下:

2023-03-25T16:01:37.png

在调用函数PHPCoroutine::create创建协程时,会校验是否已经初始化_swReactor对象,如果没有则会调用php_swoole_reactor_init函数创建并初始化main_reactor对象;

2023-03-25T16:02:46.png

我们以epoll为例,main_reactor各回调函数如下:

2023-03-25T16:03:05.png

注意:这里注册了一个函数swoole_event_wait,在生命周期register_shutdown阶段会执行该函数,开始Swoole的事件循环,阻挡了php生命周期的结束。

类Socket封装了socket读写相关的所有操作以及数据结构,其定义如下:

2023-03-25T16:03:26.png

  • socket字段类型为swConnection,代表传输层连接;
  • reactor字段指向结构体swReactor对象,用于fd事件的注册、修改、删除以及wait;
  • 当调用recv()函数接收数据,阻塞了该协程时,read_co字段指向该协程对象Coroutine;
  • 当调用send()函数接收数据,阻塞了该协程时,write_co字段指向该协程对象Coroutine;
  • 类Socket初始化函数为Socket::init_sock:

2023-03-25T16:03:55.png

当我们调用CoroutineSocket->recv接收数据时,底层实现如下:

2023-03-25T16:04:13.png

类timeout_setter会设置socket的接收数据超时时间read_timeout为timeout。

函数socket->recv_all会循环读取数据,直到读取到指定长度的数据,或者底层返回等待标识阻塞当前协程:

2023-03-25T16:04:29.png

  • 函数首先创建timer_controller对象,设置其超时时间为read_timeout,以及超时回调函数为timer_callback;
  • while(true)死循环读取fd数据,当读取数据量等于__n时,读取操作结束,break该循环;如果读取操作swConnection_recv返回值小于0,并且错误标识为SW_WAIT,说明需要等待数据到来,此时阻塞当前协程等待数据到来(函数wait_event会换出当前协程),阻塞超时时间为read_timeout(函数timer.start()用于设置超时时间)。

2023-03-25T16:04:53.png

函数swTimer_add用于添加一个定时器;Swoole底层定时任务是通过最小堆实现的,堆顶元素的超时时间最近;结构体_swTimer维护着Swoole内部所有的定时任务:

2023-03-25T16:05:30.png

当调用swTimer_add向_swTimer结构中添加定时任务时,需要更新_swTimer中最早的定时任务触发时间_next_msec,同时更新main_reactor对象的超时时间:

2023-03-25T16:06:10.png

函数wait_event负责将当前协程换出,直到注册的事件发生

2023-03-25T16:17:07.png

  • 函数add_event用于添加事件,底层调用reactor->add添加fd的监听事件;
  • read_co = co或者write_co = co,用于记录当前哪个协程阻塞在该socket对象上,当该socket对象的读写事件被触发时,可以恢复该协程执行;
  • 函数yield()将该协程换出;

上面提到,创建协程时,注册了一个函数swoole_event_wait,在生命周期register_shutdown阶段会执行该函数,开始Swoole的事件循环,阻挡了php生命周期的结束。函数swoole_event_wait底层就是调用main_reactor->wait等待fd读写事件的产生;我们以epoll为例讲述事件循环的逻辑:

2023-03-25T16:17:44.png

swReactorEpoll_wait是对函数epoll_wait的封装;当有读写事件发生时,执行相应的handle,根据上面的讲解我们知道读写事件的handle分别为readable_event_callback和writable_event_callback;

2023-03-25T16:17:57.png

可以看到函数readable_event_callback只是简单的恢复read_co协程即可;

当epoll_wait发生超时,最终调用的是函数swReactor_onTimeout,该函数会从Swoole维护的一系列定时任务swTimer中查找已经超时的定时任务,同时执行其callback回调;

2023-03-25T15:49:11.png

该callback回调函数即为上面设置的timer_callback:

2023-03-25T15:48:46.png

同样的,timer_callback函数只是简单的恢复read_co或者write_co协程即可

sleep定时器事件

Co::sleep()的实现函数为PHP_METHOD(swoole_coroutine_util, sleep),该函数通过调用Coroutine::sleep实现了协程休眠的功能:

2023-03-25T15:48:23.png

可以看到,与socket读写事件超时处理相同,sleep内部实现时通过swTimer_add添加定时任务,同时换出当前协程实现的。该定时任务会导致main_reactor对象的超时时间的改变,即修改了epoll_wait的超时时间。

sleep的超时处理函数为sleep_timeout,只需要换入该阻塞协程对象即可,实现如下:

2023-03-25T15:48:00.png

本文转载于学而思网校技术团队,2020.10月我加入了好未来励步事业部,我们事业部开始大面积应用 swoole,所以对 swoole 进行了深入学习。本文作者:陈曹奇昊

初次接触Swoole的PHP开发者多少都会有点雾里看花的感觉,看不清本质。一部分PHP开发者并不清楚Swoole是什么,只是觉得很牛掰就想用了,这种行为无异于写作文的时候总想堆砌一些华丽的辞藻或是引经据典来提升文章逼格,却背离了文章的主题,本末倒置,每一种技术的诞生都有它的原因,异步或是协程不是万能的银弹,你需要它的时候再去用它,而不是想用它而用它,毕竟编程世界的惯性是巨大的,这天下还是同步阻塞的天下。还有一部分开发者是对Swoole有了一些自己的见解,但对错参半,写出来的程序能跑,甚至也能上生产,但不是最优的,其中大部分问题都源于开发者无法将惯有的思维方式灵活转变。

协程

首先协程的最简定义是用户态线程,它不由操作系统而是由用户创建,跑在单个线程(核心)上,比进程或是线程都更加轻量化,通常创建它只有内存消耗:假如你的配置允许你开几千个进程或线程,那么开几万个几十万个协程也是很轻松的事情,只要内存足够大,你可以几乎无止境地创建新的协程。在Swoole下,协程的切换实现是依靠双栈切换,即C栈和PHP栈同时切换,由于有栈协程的上下文总是足够的小,且在用户态便能完成切换,它的切换速度也总是远快于进程、线程,一般只需要纳秒级的CPU时间,对于实际运行的逻辑代码来说这点开销总是可以忽略不计(尤其是在一个重IO的程序中,通过调用分析可以发现协程切换所占的CPU时间非常之低)。

对于Swoole这样的有栈协程,你完全可以简单地将其看做是一个栈切换器,你可以在运行的子程序中随意切换到另一个子程序,底层会保存好被切走的协程的执行位置,回来时可以从原先的位置继续往下运行。

2023-03-25T15:29:53.png

Swoole多进程模型下的进程、线程、协程关系图
但这篇文章我们要谈的并不只是单单「协程」这一个概念,还隐含了关于异步网络IO一系列的东西,光有协程是什么也做不了的,因为Swoole的协程永远运行在一个线程中,想用它做并行计算是不可能的,运行速度只会因为创建开销而更慢,没有异步网络IO支持,你只能在不同协程间切来切去玩。

实际上PHP早就实现了协程,yield关键字就是允许你从一个函数中让出执行权,需要的时候能重新回到让出的位置继续往下执行,但它没有流行起来也有多种原因,一个是它的传染性,每一层调用都需要加关键字,另一个就是PHP没有高效可靠的异步IO支持,让其食之无味。

异步

注:本文中提到的异步IO并非全为严格定义上的异步IO,更多的是日常化的表达
简单了解了协程,再让我们来理解一下什么是异步IO吧。严格来说,在Unix下我们常说的异步并不是真异步,而是同步非阻塞,但是其效果和异步非常相近,所以我们日常中还是以异步相称。同步非阻塞和真异步区别在于:真异步是你提交读写请求后直接检查读写是否已完成即可,所以在Win下这样的技术被叫做「完成端口」,而同步非阻塞仅是操作不会长时间地陷入内核,但你需要在检查到可读或可写后,调用API同步地去拷贝数据,这会不可避免地陷入内核态,但read/write通常并不会阻塞太多的时间,从宏观上整个程序仍可以看作是全异步的。

阻塞非阻塞
同步read, write(read, write) + poll/ select / epoll / kqueue
异步-aio_read, aio_write, IOCP(windows)

在实际使用中,「伪异步」的Reactor模型并不比Windows下IOCP的Proactor逊色,并且我更喜欢Reactor的可控性,当然为了追求极致的性能和解决网络和文件异步IO统一的问题,未来Linux的io_uring可能会成为新的趋势。
2023-03-25T15:30:12.png

Reactor运行流程简图
我们可以通过上面的图片简单理解Reactor模型的运行流程,所谓的「异步」不过是多路复用带来的观感效果,你的程序不会阻塞在一个IO上,而是在无事可干的时候再阻塞在一堆IO上,即IO操作不在你需要CPU的时候阻塞你,你就不会感受到IO阻塞的存在。

结合现实情景来说,以前你要买饭(IO操作),你得下楼去买,还得排队等饭店大厨做完才能取回家吃(IO阻塞),到了下一餐,你又得重复之前的操作,很是麻烦,而且越是繁忙的时候等的时间越长(慢速IO),你觉得一天到晚净排队了,极大地浪费了你写代码的时间(CPU时间)。现在有了外卖,你直接下单(异步请求)就可以继续专心写代码(非阻塞),你还可以一次定三份饭(多路IO),饭到了骑手打电话让你下楼取(事件触发),前后只花了不到几分钟(同步读写,如果是Proactor连取餐都省了,直接给你送上楼),周六晚上的九点,你终于合上电脑,觉得充实极了,因为你几乎一整周都在写代码(CPU利用率高)。

协程+异步=同步非阻塞编程

现在我们有了协程和异步,我们可以做什么呢?那就是异步的同步化。这时候有的开发者就会说了,诶呀好不容易习惯异步了,怎么又退回到同步了呢。这就是为什么有些开发者始终写不出最优的协程代码的原因,异步由于操作的完成不是立即的,所以我们需要回调,而回调总是反人类的,嵌套的回调更是如此。

而结合协程,消灭回调我们只需要两步:在发出异步请求之后挂起协程,在异步回调触发时恢复协程。

Swoole\Coroutine\run(function(){
    // 1. 创建定时器并挂起协程#1
    Swoole\Coroutine::sleep(1);
    // 3. 协程恢复,继续向下运行退出,再次让出
});
// 2. 协程#1让出,进入事件循环,等待1s后定时器回调触发,恢复协程#1
// 4. 协程#1退出并让出,没有更多事件,事件循环退出,进程结束
短短的一行协程sleep,使用时几乎与同步阻塞的sleep无异,却是异步的。
for ($n = 10; $n--;) {
    Swoole\Coroutine::create(function(){
        Swoole\Coroutine::sleep(1);
    });
}

我们循环创建十个协程并各sleep一秒,但实际运行可以发现整个进程只阻塞了一秒,这就表明在Swoole提供的API下,阻塞操作都由进程级别的阻塞变为了协程级别的阻塞,这样我们可以以很小的开销在进程内通过创建大量协程来处理大量的IO任务。

协程代码编写思路

定时任务

当我们说到定时任务时,很多人第一时间都想到定时器,这没错,但是在协程世界,它不是最佳选择。

$stopTimer = false;
$timerContext = [];
$timerId = Swoole\Timer::tick(1, function () {
    // do something
    global $timerContext;
    global $timerId;
    global $stopTimer;
    $timerContext[] = 'data';
    if ($stopTimer) {
        var_dump($timerContext);
        Swoole\Timer::clear($timerId);
    }
});
// if we want to stop it:
$stopTimer = true;

在异步回调下,我们需要以这样的方式来掌控定时器,每一次定时器回调都会创建一个新的协程,并且我们不得不通过全局变量来维护它的上下文。

如果是协程呢?

Swoole\Coroutine\run(function() {
    $channel = new Swoole\Coroutine\Channel;
    Swoole\Coroutine::create(function () use ($channel) {
        $context = [];
        while (!$channel->pop(0.001)) {
            $context[] = 'data';
        }
        var_dump($context);
    });
    // if we want to stop it, just call:
    $channel->push(true);
});

完全同步的写法,从始至终只在一个协程里,不会丢失上下文,channel->pop在这里的效果相当于毫秒级sleep,并且我们可以通过push数据去停止这个定时器,非常的简单清晰。

Task

由于开发者的强烈要求,Swoole官方曾经做了一个错误的决定,就是在Task进程中支持协程和异步IO。

图片
正如图中所示,Task进程最初被设计为用来处理无法异步化的任务,充当类似于PHP-FPM的角色(半异步半同步模型),这样各司其职,能够将执行效率最大化。

最早期的Swoole开发者,甚至直接将Swoole的Worker进程用于执行同步阻塞任务,这种做法并非没有可取之处,它比PHP-FPM下的效率更高,因为程序是持续运行,常驻内存的,少了一些VM启动和销毁的开销,只是需要自己处理资源的生命周期等问题。

此外就是使用异步API的开发者,他们会开一堆Task进程,将一些暂时无法异步化的同步阻塞任务丢过去处理。

而以上两种都是历史条件下正确并合适的Swoole打开方式。

但是还有一小撮开发者,一股脑地把所有任务都投递给Task进程,以为这样就实现了任务异步化,Worker进程除了接收响应和投递任务什么也不干,殊不知这就相当于每一个任务的处理多了两次数据序列化开销 + 两次数据反序列开销 + 两次IPC开销 + 进程切换开销。

而当协程逐渐成为新的趋势后,又有越来越多的社区呼声要求Task进程也能支持协程和异步IO,这样他们就可以将协程方式编写的任务投递到Task中执行。但异步任务可以很轻量地在本进程被快速处理掉,对Worker整体性能并不会有太大影响,他们这样的行为,也是典型的舍近求远。

Task方式处理协程任务

$server->on('Receive', function(Swoole\Server $server) {
    # 投递任务,序列化任务数据,通过IPC发送给Task进程
    $task_id = $server->task('foo'); 
});
# 切换到Task进程
# 接收并反序列化Worker通过IPC发送来的任务数据
$server->on('Task', function (Swoole\Server $server, $task_id, $from_id, $data) {
    # 使用协程DNS查询
    $result = \Swoole\Coroutine::gethostbyname($data);
    # 序列化数据,通过IPC发送回Worker进程
    $server->finish($result);
});
# 回到Worker进程
# 接收并反序列化Task通过IPC发送来的结果数据
$server->on('Finish', function (Swoole\Server $server, int $task_id, $result) {
    # 需要通过任务id才能确认是哪个任务的结果
    echo "Task#{$task_id} finished";
    # 打印结果
    var_dump($result);
});

协程方式写Task

注:batch方法由swoole/library提供,内置支持需要Swoole-v4.5.2及以上版本,低版本可以自己使用Channel来调度

use Swoole\Coroutine;

Coroutine\run(function () {
    # 并发三个DNS查询任务
    $result = Coroutine\batch([
        '100tal' => function () {
            return Coroutine::gethostbyname('www.100tal.com');
        },
        'xueersi' => function () {
            return Coroutine::gethostbyname('www.xueersi.com');
        },
        'zhiyinlou' => function () {
            return Coroutine::gethostbyname('www.zhiyinlou.com');
        }
    ]);
    var_dump($result);
});

输出(API保证返回值顺序与输入顺序一致,不会因为异步而乱序)

array(3) {
  ["100tal"]=>
  string(14) "203.107.33.189"
  ["xueersi"]=>
  string(12) "60.28.226.27"
  ["zhiyinlou"]=>
  string(14) "101.36.129.150"
}

非常的简单易懂,不存在任何序列化或者IPC开销,并且由于程序是完全非阻塞的,大量的Task任务也不会对整体性能造成影响,所以说Task进程中使用协程或异步完全就是个错误,作为一个程序员,思维的僵化是很可怕的。

读到这里大家应该也能明白,我们所谈论的协程化技术实际上可以看做传统同步阻塞和非阻塞技术的超集,非阻塞的技术让程序可以同时处理大量IO,协程技术则是实现了可调度的异步单元,它让异步程序的行为变得更加可控。如果你的程序只有一个协程,那么程序整体就是同步阻塞的;如果你的程序在创建某个协程以后不关心它的内部返回值,它就是异步的。

希望通过本文,大家能够加深对协程和异步IO的理解,写出高质量可维护性强的协程程序。