0%

在上周一次跟同事聊天的时候,他提到自己最近在尝试搭建一个 gerrit 服务器(一个代码审查平台),但是尝试多次未果,而且还将 ubuntu 虚拟机多次搞崩重装。

(这让我想起了自己大学时候折腾 ubuntu 时也是搞崩了无数次,而且不是虚拟机,有一段时间里,每天就重复着安装、崩溃、重装的过程,说实话有点枯燥无味,不过这个过程中熟悉了不少 linux 常用命令的使用。下图是以前折腾出来的一个 ubuntu 的 3D 效果,em... 当初搞这个纯粹是觉得酷炫,实际上没什么用。)

ubuntu-3d.jpg

然后我表示为什么不用 Docker,比虚拟机方便多了,然后才得知,原来他觉得虚拟机也很方便,但实际上很多场景下跑个 Docker 容器比启动一个虚拟机方便多了。最后应对方要求,写一篇关于 Docker 入门的指南,也就有了本文。

本文旨在帮助大家建立起对 Docker 的一个整体印象,至于具体如何使用更多是需要自己去实践。毕竟 Docker 的内容太多了,但我们实际用到的内容其实只是它最核心的一部分。

先通过这个实际的例子来比较一下传统虚拟机跟 Docker 之间在使用上存在的时间差别吧。

Docker 启动一个 gerrit 有多简单

对于大部分开发者来说,可能需要做的只是使用,比如,gerrit,为了达到快速体验一项技术的目的,我们可以直接忽略掉运行环境的复杂性(当然,想卷起来也可以去自己搞懂整个部署流程,甚至去看看源代码什么的)。

而 Docker 就为我们提供了这一种便利,通过 Docker,我们通过一两个命令就能启动一些我们想要的软件,比如下面两个命令就可以直接启动一个 gerrit 服务器:

1
2
docker pull gerritcodereview/gerrit
docker run -ti -p 8080:8080 -p 29418:29418 gerritcodereview/gerrit
gerrit.png

虚拟机的方式有多麻烦

至此,我们两条命令就可以把一个 gerrit 服务器运行起来了,不需要先安装一个虚拟机软件,再在虚拟机里面装一个系统,然后再安装 gerrit 需要的依赖,然后再部署 gerrit。

我们可以通过下图对比一下:

comparison.png

左边的第 5 步和右边的第 2 步是一样的。具体启动时间取决于应用本身。

我们可以明显看到通过虚拟机的方式我们需要做的步骤明显多了,而且其中每一步需要的时间都是一个未知数,如果对 linux 本身也不太熟悉,那这个过程可能就更长了。

相比之下,使用 Docker 的方式,我们只需要简单的两步就可以达到同样的目的。如果本身就不熟悉 linux,然后又要在真实的 linux 环境下部署一个,花的这些部署时间可能足够熟悉 gerrit 本身了。

Docker 是什么

我们可以从两个角度简单理解:

  1. 我们尽可以简单地把 Docker 看作是类似传统虚拟机一样地东西,本质上也是一种虚拟化的技术。但相比传统虚拟机,它更加轻量、使用更加便捷。

最直观的,我们可以直接从现有的镜像开始,构建我们的应用程序。而不用像传统虚拟机那样,自己先在虚拟机上安装一个操作系统,再去安装依赖,再安装我们真正需要的软件。

开始下文之前,让我们再来重新审视一下 Docker 的 logo 本身:

docker-logo.png

这个 logo 看上去像是一艘载着货物的船,Docker 本身就是这么运作的,通过启动一个 Docker 引擎,然后不同的容器可以运行在 Docker 引擎之上。

另外,我们开发的时候可以基于 Docker 容器提供的环境开发,我们开发完的应用可以打包成 Docker 镜像,然后传输到不同服务器上,而往往我们的应用是由多个依赖组成的(这些依赖也可以构建出一个个的镜像),然后再在服务器上通过我们传输的这一系列镜像启动不同的容器。

同时,Docker 引擎本身是不同平台都可用的,所以我们的应用并不依赖于实际的平台,只依赖于 Docker 引擎本身(实际上我们所需要的依赖都在不同的镜像里面包含了,并不是只有 Docker 引擎就够了)。

所以,我们可以有另外一个关于 Docker 是什么的描述:

  1. Docker 是一个用于开发、传送和运行应用程序的开放平台。Docker 使您能够将应用程序与基础设施分开,以便您可以快速交付软件。使用 Docker,您可以像管理应用程序一样管理基础设施。

Docker 跟虚拟机对比

docker-vs-vm.png

我们可以看到,传统虚拟机中,可能需要虚拟出多个不同的操作系统环境,才能实现在不同操作系统环境运行我们需要的软件。比如,我们一个应用依赖于 CentOS 环境,另外一个应用依赖于 Ubuntu 环境,我们就需要安装两个操作系统对应的虚拟机,这一步是非常耗时而麻烦的,而且,这个虚拟出来的操作系统环境也是很占资源的。

而在 Docker 里面,Docker 的容器引擎提供了我们容器所需的环境,而且所有的容器都是使用唯一一个容器引擎,相比较之下,传统虚拟机的方式显得有点笨重。

总的来说,Docker 相比传统虚拟机有以下优势:

  1. 使用 Docker 的情况下不需要为每一个容器虚拟出一个操作系统环境,而传统虚拟机的方式如果需要不同的操作系统环境支持,则必须要构建出不同的操作系统环境(就算我们这个虚拟机不运行其他任何东西,这个虚拟出来的操作系统就已经占用很多资源了)。
  2. Docker 启动一个容器所需要的资源非常小,比如一个 nginx 启动起来的容器可能就需要占用几 M 的内存。
  3. Docker 启动一个容器所需要的时间非常短,启动容器的过程可能只需要一瞬间,相比传统虚拟机启动一个虚拟操作系统快很多倍。

Docker 里面镜像跟容器是什么?

上面有两个反复提到的词,"镜像"、"容器",这两个是 Docker 里面核心的几个概念之一了,所以很有必要先解释一下。

我们理解镜像跟容器,可以对比一下程序和进程:

  1. 什么是程序,一个可以直接运行的二进制文件我们称它为程序,它本质上是一个文件。比如,redis 二进制文件(redis 安装目录里面 bin/redis 这个文件),这个就是一个程序。
  2. 什么是进程,我们运行程序的时候,操作系统会创建一个进程,这个进程里面做的事情就是我们预先在程序里面定义好的。比如,运行 bin/redis 的时候,我们的计算机上就多了一个 redis 的进程。

有了程序跟进程的大概印象,再来理解一下镜像跟容器:

  1. 什么是镜像,镜像实际上也是一个静态的概念,实现上可能表现为磁盘上的一个文件或者多个文件的集合。
  2. 什么是容器,容器就是我们通过 Docker 引擎运行的一个镜像的实例。

我们可以从一个程序启动多个进程,同样的,我们也可以从一个镜像启动多个容器。所以,我们可以将镜像视作 Docker 里面的程序,将容器视作 Docker 里面的进程。

实际上,Docker 容器本质上就是一个进程,这个进程跟传统进程不一样的是,它依赖的东西由镜像提供,而不是操作系统提供(比如动态链接库这些)。所以 Docker 镜像的作用是为我们程序运行提供所需要的依赖。

Docker 架构

这里引用一下官网文档里面的架构图:

docker-arch.png

理解了这个图,我们实际上就已经掌握了 Docker 的核心。

我们可以通过上面的例子来讲述使用 Docker 的实际步骤:

  1. 安装配置 Docker,启动 Docker。这一步没啥好说的

  2. 拉取 gerrit 镜像。

这一步我们会从 register 获取 gerrit 镜像文件,拉取的过程就是,我们在命令行上运行 docker pull gerritcodereview/gerrit 的过程,这个过程会做以下事情:我们的命令会发送给 Docker 引擎(也就是图上中间的 Docker daemon),Docker 引擎会从 register 下载 gerritcodereview/gerrit 这个镜像文件到本地,最后退出

  1. 启动 gerrit 容器。(这一步我们会从 Docker 的 gerrit 镜像启动一个 gerrit 容器)

这一步我们通过运行 docker run -ti -p 8080:8080 -p 29418:29418 gerritcodereview/gerrit 实现,这个过程,Docker 引擎会启动一个新的进程,这个进程里面运行的就是我们镜像指定的程序(这个下面细说,现在需要记住,一个容器本质上就是一个进程)。

好了,现在我们对于 Docker 的运行机制有了一个大概的印象了,接下来可以详细说说这个图:

Client

  1. 这个是指我们运行命令的工具,最常见的就是 shell,比如:
1
docker pull redis

这里,我们的终端(windows 下是 cmd 窗口,macos 是 terminal 窗口)就是一个 client。

  1. 当然也可以是其他自己实现或者第三方实现的,因为 Docker 还可以通过 rest api 的方式来操作。所以只要可以向 Docker 引擎发起 HTTP 请求,都可以视作是一个 Client。

DOCKER_HOST

这是 Docker 的核心部分,我们的 Docker 引擎位于这一部分,这里面也细分为三个主要部分:Docker daemon、Containers、Images。

Docker daemon

这个在实际使用中就是一个 dockerd 进程,就是一个 Docker 的守护进程。它的作用是,监听客户端的请求(比如我们运行 docker run 的时候就是向 Docker daemon 发起了一个请求),同时管理 Docker 里面的所有其他东西,比如容器、镜像、网络、卷等。

Containers

这一块就是一堆的容器,每一个容器本质上就是一个进程。(当然实际使用中,一个容器可能是有多个关联的进程的,比如它的子进程)

Images

这一块就是一堆的镜像,本质上是一堆的静态文件的集合。

Registry

Registry 就是存储 Docker 镜像的地方。类似 github,只不过上面保存的是 Docker 镜像,同样我们也可以上传自己的镜像上去。

一般来说我们都是使用 Docker 官方的 Registry,我们也可以搭建自己私有的 registry,用来存储一些不想对外公开的 Docker 镜像。

Docker 工作流程

我们来看看一个带了序号标记的 Docker 架构图:

docker-arch-1.png

上面也说了,理解了 Docker 的架构图,也就掌握了 Docker 的核心。所以这里详细说一下它的核心工作流程:

  • 1+4:运行 docker build 的时候,Docker daemon 会根据我们定义的 Dockerfile 文件来生成一个镜像文件,这个镜像文件保存到本机(4)。

这一步的作用是构建我们自己的镜像,最常见的应用场景是,软件官方提供的镜像不能够满足我们的需要,我们需要在官方提供的镜像的基础上,做一些自己的改动,比如添加一些我们所需要的依赖(比如,往 php 镜像里面加几个扩展)。

  • 2+7+5:运行 docker pull 的时候,Docker daemon 会根据我们在后面传递的参数,从 Registry 上面下载对应的镜像文件,7 是向 Registry 服务器发起请求,5 是 Registry 服务器响应请求,最终的结果就是,我们本地的镜像里面多了一个镜像。

这一步的作用是拉取我们所需要的镜像,不管是不是我们自己构建的镜像,都会发生这一步。如果我们是从别人提供的镜像上构建一个新的镜像,我们运行 docker build 构建属于我们自己的镜像的时候,就会从 Registry 拉取基础镜像。

如,docker pull redis,这个命令会从 Registry 下载 redis 最新的镜像文件,然后保存到本地。这个命令也可以指定版本,如 docker pull redis:5 这个命令下载 tag 为 5 的 redis 镜像。至于有哪些 tag 可以用,我们可以在官网的 Registry 上面搜索一下。

  • 3+8+6(可能会发生 7+5 这两步操作):运行 docker run 的时候,Docker daemon 会根据参数从本地的镜像列表获取对应的镜像(7),然后从这个镜像启动一个容器(6)。如果本地没有这个镜像,会从 Docker Registry 先拉取这个镜像(跟上一点说到的流程一致)。

这一步的作用就是我们实际所需要的,它会从镜像启动一个新的容器。

如,docker run redis,这个命令会看本地有没有 redis:latest 这个镜像(tag 省略的话,默认是 latest),有的话,直接从这个镜像启动一个容器,如果本地没有这个镜像,则会先从 Registry 上下载这个镜像,然后再启动容器。

再捋一捋 Docker 里面的几个核心概念

  • Docker daemon

这个是 Docker 的守护进程,如果我们想使用 Docker 的话,首先得把 Docker 启动起来是吧,我们启动起来的那个进程就是 Docker daemon。

  • 镜像(Image)

官方描述:一个 Docker 镜像就是创建 Docker 容器的只读模板。通俗地说,镜像就是一个我们程序的运行环境,当然你也可以将自己的程序也放进镜像里面。

  • 容器(Container)

官方描述:一个 Docker 容器就是一个 Docker 镜像的实例。本质上就是,一个进程,而这个进程所需要的依赖由镜像提供。

  • Registry

保存镜像的地方。我们一般是使用官方提供的,也可以搭建自己的 Registry 用来保存一些私有镜像,或者为了达到加速拉取镜像的目的。

容器本质上是一个进程

上一小节说了,镜像就一个我们程序的运行环境。虽然我们用了程序跟进程的概念来做比较,但本质上,一个 Docker 镜像只是为我们程序运行提供环境。而我们的容器,实际上是我们在这个容器里面执行某个程序产生的进程。

比如,我现在有一段 nodejs 文件:

test.js,位于目录 /Users/ruby

1
2
3
4
5
6
7
8
9
const fs = require('fs')

try {
const data = fs.readFileSync('/test.js', 'utf8')
console.log('data:')
console.log(data)
} catch (err) {
console.error(err)
}

但是我本机没有安装 nodejs,现在想通过 Docker 来执行我们这段代码,具体需要怎么做呢?

  1. 拉取 node 镜像
1
docker pull node
  1. 从 node 镜像创建一个容器来运行我们这个文件
1
docker run -v /Users/ruby/test.js:/test.js node node /test.js

这个命令怎么理解

这个命令很长,所以画了个图来更直观地阐释一下:

docker-run.png

这个图里面的命令有两种颜色区分开两种不同含义的内容:

第一部分表示是 Docker 相关的命令以及参数,而第二部分就是我们的程序名称以及传递给我们程序的参数。

需要注意的地方是:

  • Docker 里面针对不同的内容有不同的二级命令,比如管理镜像是 docker image,管理 Docker 网络是 docker network
  • 图中的 3 是一系列的选项以及参数,当然这部分是可以省略的,如果我们的确不需要什么参数。
  • 图中的 4 是镜像的名称,表示我们基于哪个镜像提供的运行环境来运行 5 那个程序。比如,我们想用一个 12.0.0 的版本来运行我们的程序,我们就可以 docker run -v /Users/ruby/test.js:/test.js node:12.0.0 node /test.js。(需要特别注意的是,这里的 4 跟 5 看起来一样,但本质上是完全不同的)
  • 5 以及后面那部分,也是一个命令以及参数,只不过这个命令是运行在 4 这个镜像提供的运行环境上的,而不是在我们的宿主机上运行的。另外,第 6 那个 /test.js 以及后面接的所有其他内容,都是传递给容器里面的 node 程序的参数。

为什么说容器是一个进程

经过上面对于命令图解,我们也看到了,实际上到最后,我们运行的命令是 node /test.js,而我所说的 "容器是一个进程" 说的就是这个,我们启动容器最后的步骤其实就是运行我们指定的程序。

只不过相比较于我们直接在宿主机上运行 node /test.js,通过容器的方式我们使用的是 Docker 镜像提供的运行环境。如果我们想在宿主机上运行,我们就需要在宿主机上安装 node 的运行环境。

为什么执行命令之后就容器就退出了

我们执行了上面那个 docker run 命令之后,输出了一些东西然后就退出了,可能有很多初学者会觉得很奇怪,为什么退容器也出了。这也跟上面说的容器是一个进程本质上是同一个问题,进程都是做完它应该做的事情就退出了的,把容器看作进程就很容易理解了。

上面这个也一样,在容器里面执行完 node /test.js 的时候,node 的工作就已经完成了,所以进程就退出了。具体表现就是,我们的容器也停止了。

如何指定容器要执行的程序

  1. 如果是通过 Dockerfile 来构建的镜像,可以在 Dockerfile 里面最后一行通过 RUN 指令来指定。
  2. 如果使用 docker-compose,则可以在 docker-compose 里面通过 command 或者 entrypoint 来指定。
  3. 可以在 docker run 命令的镜像名称参数后面指定(上图的 5)。

启发

我们在使用 Docker 的过程中,尽管可以把容器理解为一个进程,我们需要什么样的依赖,就通过 Docker 来启动一个容器(进程)。比如,我们想使用 redis,就可以通过 docker run 来启动一个 redis 进程(redis 容器)。

这样看来,其实 Docker 也没有什么神秘的地方了,我们使用起来也不会有太多的心智负担。

如何开始使用 Docker

花了很大的篇幅来讲 Docker 的架构,现在基于它的架构回到最初的例子来解释一下如何开始使用 Docker。

目标:启动一个 gerrit 服务器。

详细步骤:

  1. 上面说了,镜像提供了我们所需运行的进程的环境。所以这一步我们需要先拉取 gerrit 对应的镜像。

所有我们能想到的常用的软件基本上都有现成的镜像,具体可以在 hub.docker.com 上面搜索一下,我们在上面搜索一下 "gerrit":

docker-search.png

我们这里需要注意一下,有一些名字一样的,但不是官方提供的,我们可以看一下右上角有多少下载就可以知道哪些是官方提供的了。当然这个图上面官方镜像说明那里也明说了,"Official Gerrit Code Review Docker image"。

我们点击进去看看,右边的 "Docker Pull Command" 就我们拉取这个镜像的命令,点击一下红框部分就可以复制这个命令了:

docker-image-detail.png

复制之后,在命令行运行这个命令:

1
docker pull gerritcodereview/gerrit

成功运行这个命令之后,我们本地就有了 gerritcodereview/gerrit 这个镜像了。我们可以通过 docker images 列出我们目前的镜像列表:

1
docker images

输出:

1
2
3
REPOSITORY                TAG            IMAGE ID       CREATED        SIZE
node latest 3b66eb585643 2 days ago 905MB
gerritcodereview/gerrit latest fa5925fec1e3 7 weeks ago 693MB
  1. 上面也说了,容器就是基于镜像启动的一个进程。所以这一步我们需要做的是,通过我们下载的 gerritcodereview/gerrit 镜像来启动一个容器。

这一步也很简单,docker run 然后再指定一个镜像名称就行了:

1
docker run -ti -p 8080:8080 -p 29418:29418 gerritcodereview/gerrit

不出以外的话,过一会应该可以看到如下输出:

1
2
3
4
5
[2021-10-25T07:21:58.234Z] [main] INFO  org.eclipse.jetty.server.handler.ContextHandler : Started o.e.j.s.ServletContextHandler@174e79f9{/,null,AVAILABLE}
[2021-10-25T07:21:58.271Z] [main] INFO org.eclipse.jetty.server.AbstractConnector : Started ServerConnector@6e8426aa{HTTP/1.1, (http/1.1)}{0.0.0.0:8080}
[2021-10-25T07:21:58.272Z] [main] INFO org.eclipse.jetty.server.Server : Started @9345ms
[2021-10-25T07:21:58.280Z] [main] INFO com.google.gerrit.pgm.Daemon : Gerrit Code Review 3.4.1 ready
[2021-10-25T07:23:11.551Z] [plugin-manager-preloader] INFO com.googlesource.gerrit.plugins.manager.OnStartStop : 65 plugins successfully pre-loaded

这里的日志可以看出我们的服务器正常启动了,再通过浏览器访问 http://localhost:8080 就可以访问 gerrit 了。

这里的 -it 可以在启动之后直接在前台运行,-p 指定端口映射,可以指定多个。

到此为止,我们的目的就已经达到了。重要的步骤就这两步了。

什么时候使用 Docker

  1. 想使用某个软件,但是又不想在自己的电脑上安装太多东西。(这也是很多人使用 Docker 的原因)
  2. 只是想体验一下某项技术(比如,PHP8 出来了,想体验一下语言的新特性),这种情况下,我们的目的很单纯,就是想用一用这项技术(如果只是为了验证一行代码而花了大量时间在环境搭建上就本末倒置了)。我们都知道在宿主机上安装一个 PHP 并且跑起来是一件非常费劲的事,有那个时间都够在容器里面把新特性跑个遍了。
  3. 团队统一环境。在以前,很多人会采用虚拟机的方式,但是 Docker 比传统虚拟机的方式不止方便一点点。
  4. 生产环境部署使用,Docker 相比以往的部署方式,直接屏蔽了不同平台的差异,只要 Docker 能安装,容器一般也就能跑,所以部署成本低了很多。同时也可以限制每个容器所能使用的资源,防止某个进程占用资源过多对整个服务器造成影响。另外,配合 k8s 也可以很方便对这些 Docker 容器进行管理。

总结

  1. Docker 本质上也是一种虚拟化技术,但是相比传统虚拟机,不需要每一个容器都创建一个操作系统环境,所有的容器都依赖于 Docker 引擎。
  2. Docker 镜像是我们程序的运行环境。
  3. Docker 容器本质上是在镜像提供的环境上运行的一个进程。
  4. Docker daemon 是 Docker 用以管理镜像、容器等东西的一个守护进程。
  5. Docker Registry 是我们拉取镜像的地方,当然我们也可以将我们自己构建的镜像上传到 Registry。(可以理解为镜像的托管平台,类似于 github 跟开源项目的关系。)
  6. Docker 容器执行的程序,在进程终止的时候,容器也会停止运行。

后记

很遗憾,本文不是一个速成的指南,也没有说到什么 Docker 命令的实际使用,也没有介绍什么 Docker 相关工具的使用(比如 docker-compose),毕竟说了也没什么用,大家也记不住,最终使用的时候还是会去搜索对应的命令是什么。不过相对完整地向读者展现了 Docker 的架构以及一个整体的运作流程,足以帮助读者建立起对 Docker 的基本印象了。

至于其他的东西,比如 docker run 后面有哪些选项、又或者怎么构建自己的镜像、怎么使用 docker-compose 等等这些其实开始使用的时候不需要了解太多,在真正需要用到的时候搜索一下就行了,我们的记忆力毕竟是有限的,而且记住太多东西也不一定会帮助我们去更好地使用。

反而,虽然我们只是花了少量时间去建立起了对 Docker 的一个整体印象,但是对于实际使用中遇到的百分之八九十的问题,我们都可以回到这个整体印象中来,去思考是哪个环节出的问题。也就是说,我们的思考有了一个抓手,就算问题出现了,我们也可以根据现象跟这个整体印象建立联系,从而找到解决问题的方向,而不是迷失在纷繁芜杂的细节中。

  1. UI 操作是通过以下的方式来操控具体的 app 的
1
2
3
4
5
6
7
tell application "Capture One 21"
tell application "System Events"
tell process "Capture One 21"
-- 这里是对具体应用的 UI 模拟操作
end tell
end tell
end tell
  1. 模拟按下键盘

使用键盘按键对应的 key code,示例:

1
2
3
tell application "System Events"
key code 49
end tell

所有 keycode:

keycode.png
  1. 模拟按下组合键

如:shift+command+G

1
key code 5 using {shift dow, command down}
  1. 查看所有可操作的 UI 元素可使用 UI elements,这一点非常有用,在 app 不支持 applescript 的时候,我们只能通过模拟人为去操作 UI 元素,而这个 UI elements 可以让我们具体元素的名称是什么。
1
2
3
4
5
6
7
tell application "Capture One 21"
tell application "System Events"
tell process "Capture One 21"
UI elements
end tell
end tell
end tell

输出:

1
{window "Capture One Catalog" of application process "Capture One 21" of application "System Events", menu bar 1 of application process "Capture One 21" of application "System Events"}

如果我们想再获取 UI 元素里面的子元素,可以使用 of,如:

1
2
3
4
5
6
7
tell application "Capture One 21"
tell application "System Events"
tell process "Capture One 21"
UI elements of window "Capture One Catalog" -- 这里的 window "Capture One Catalog" 是通过 UI elements 获取的元素
end tell
end tell
end tell

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{image 1 of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
static text "1/90 s" of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
slider 1 of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
button 1 of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
static text "ISO 200" of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
static text "Alex_Benes.RAF" of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
UI element 7 of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
static text "120" of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
static text "148" of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
static text "125" of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
static text "100" of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
button 2 of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
button 3 of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
button 4 of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
group 1 of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
static text "1/6" of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
button 5 of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
button 6 of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
button 7 of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
button 8 of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
group 2 of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
radio group 1 of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
toolbar 1 of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
button 9 of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
button 10 of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
button 11 of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
image "Capture One.cocatalogdb" of window "Capture One Catalog" of application process "Capture One 21" of application "System Events",
static text "Capture One Catalog" of window "Capture One Catalog" of application process "Capture One 21" of application "System Events"}

同理,如果我们再想获取下一级的子元素,同样可以使用 of,如:

1
2
3
4
5
6
7
tell application "Capture One 21"
tell application "System Events"
tell process "Capture One 21"
UI elements of group 1 of window "Capture One Catalog"
end tell
end tell
end tell

输出:

1
{scroll area 1 of group 1 of window "Capture One Catalog" of application process "Capture One 21" of application "System Events"}
  1. 我们可以使用 script editor 的 File->Dictionary 来查看某一个 app 具体有哪些命令或者类,这样我们就可以在 applescript 中使用它们,这个可能不太准确。

准确的层级结构可以 script debugger 来查看,在 script debugger 里面输入如下一行即可查看:

1
tell application "Capture One 21" to set appObjectModel to it
  1. 可以使用 xcode 里面的 Accessibility Inspector 来查看大概有哪些 UI 元素

  2. 查看应用里面的属性(在 apple script 里面,一个应用实际上会映射为 apple script 里面的一个对象,就是 application xx 这是一个 apple script 对象)

1
2
3
4
5
6
tell application "Capture One 20" to set a to it

tell application "Capture One 20"
get properties -- 获取 "Capture One 20" 的属性
get properties of viewer of a -- 获取 "Capture One 20" 里面的 viewer 里面的属性
end tell

我们可以通过这种方式获取到一个应用的对象结构。

参考链接:

  1. applescript key code
  2. A Strategy for UI Scripting in AppleScript
  3. UserInterfaceScripting

核心选项

  • -h, --help 显示所有可用的选项

  • --version 显示版本信息

  • --config <filename>, -f <filename> 指定配置文件路径

  • --configExpand <none|rest|exec> 4.2 版本新增,启用在配置文件中使用扩展指令。 扩展指令允许您为配置文件选项设置外部来源的值。

    • none 默认,mongodb 不扩展扩展指令。 如果任何配置文件设置使用扩展指令,mongod 将无法启动。
    • rest mongod 在解析配置文件时扩展 __rest 扩展指令。
    • exec mongod 在解析配置文件时扩展 __exec 扩展指令。

您可以将多个扩展指令指定为逗号分隔的列表,例如 rest,exec 如果配置文件包含未指定给 --configExpand 的扩展指令,则 mongod 返回错误并终止。

主要目的:用于从外部获取一些动态的配置值(比如运行命令获取-exec,或者通过 http 请求获取-rest)。

  • --verbose, -v 增加在标准输出或日志文件中返回的内部报告数量。 通过多次包含选项来增加 -v 形式的详细程度(例如 -vvvvv。)

  • --quiet 在尝试限制输出量的安静模式下运行 mongodb。(不会记录数据库命令的输出、复制集活动信息、连接建立事件、连接关闭事件)

  • --port <port> 指定端口。默认 27017,如果是分片机器默认是 27018,如果是 config server 默认是 27019。

  • --bind_ip <hostnames|ipaddresses|Unix domain socket paths> 默认 localhost。可以指定多个,如:localhost,/tmp/mongod.sock。如果用 ipv6 的地址,启动的时候需要使用 --ipv6 选项。监听所有的 ipv4 地址,使用 0.0.0.0。监听所有的 ipv6 地址,使用 ::

  • --bind_ip_all 3.6 新增。如果指定了这个选项,mongod 实例绑定到 0.0.0.0,如果使用了 --ipv6 选项,则绑定到 ::。只是指定 --bind_ip_all 的时候不会绑定到 ipv6 的 ::

不能同时指定 --bind_ip--bind_ip_all

  • --clusterIpSourceAllowlist <string> mongod 验证来自副本集其他成员的身份验证请求的 IP 地址/CIDR(无类别域间路由)范围列表,以及 mongos 实例(如果是分片集群的一部分)。 mongod 验证原始 IP 是否明确在列表中或属于列表中的 CIDR 范围。 如果 IP 地址不存在,则服务器不会对 mongodmongos 进行身份验证。

  • --clusterIpSourceWhitelist <string> 5.0 废弃。使用 --clusterIpSourceAllowlist 代替。

  • --ipv6 启用 ipv6 支持。默认不支持 ipv6。指定这个选项的时候,还需要通过 --bind_ip 来指定一个 ipv6 地址,又或者使用 --bind_ip_all 来绑定到 ::

  • --listenBacklog <number> 3.6 版本新增,目标系统的 SOMAXCONN 常量。listen queue 中可以存在的最大连接数。为了防止未定义行为出现,指定的数值必须位于 1 跟系统的 SOMAXCONN 之间的一个数。

  • --maxConns <number> 最大连接数

  • --logpath <path> 日志路径。默认情况下,mongod 会创建一个新的文件,如果我们在再次启动的时候想以追加的方式写入日志,可以加上 --logappend 选项。

  • --syslog 将所有日志输出到系统日志而不是日志文件(--logpath),windows 不支持。(4.2

  • --syslogFacility <string> 指定将消息记录到 syslog 时使用的工具级别。 您指定的值必须由您的操作系统的 syslog 实现支持。 要使用此选项,您必须启用 --syslog 选项。

  • --logappendmongod 实例重新启动时,将新条目附加到现有日志文件的末尾。 如果没有此选项,mongod 将备份现有日志并创建一个新文件。

  • --logRotate <string> 默认 rename。确定轮换服务器日志和/或审计日志时 logRotate 命令的行为。 可选值为 renamereopen

rename 重命名日志文件。

reopen 关闭并重新打开日志文件,当使用 Unix/Linux 的 logrotate 工具时使用 reopen 可以防止日志丢失。如果指定了 reopen,你必须同时使用 --logappend 选项。

  • --timeStampFormat <string> 默认 iso8601-local,日志里面的时间戳格式。

iso8601-utc 例子:1970-01-01T00:00:00.000Z

iso8601-local 例子:1969-12-31T19:00:00.000-05:00

  • --traceExceptions 仅供内部诊断使用

  • --pidfilepath <path> 指定保存 mongod 进程 pid 文件的路径。运行 mongod 或者 mongos 进程的用户必须有这个路径的写权限。如果不指定这个选项,则进程不会创建 PID 文件。此选项通常仅与 --fork 选项结合使用才有用。

在 Linux 上,PID 文件管理通常由发行版的初始化系统负责,如 /etc/init.d 或者 systemctl。只有当你没有使用这些初始化系统的时候,才使用 --pidfilepath 选项。

在 macOS 上,PID 文件通常由 brew 来处理。只有当你没有使用 brew 的时候才使用 --pidfilepath 选项。

  • --keyFile <file> 指定密钥文件的路径,该文件存储 MongoDB 实例用于在分片集群或副本集中相互验证的共享密钥。 --keyFile 意味着 --auth

  • --setParameter <options> 指定 MongoDB 服务器参数 中描述的 MongoDB 参数之一。 您可以指定多个 setParameter 字段。

  • --nounixsocket 禁用侦听 UNIX 域套接字。 --nounixsocket 仅适用于基于 Unix 的系统。mongod 进程会一直监听 unix 套接字,除非以下几个条件任意一个满足:

    • --nounixsocket 被设置
    • net.bindIp 没有设置
    • net.bindIp 没有指定 localhost 或者它关联的 IP 地址

从官方的 .deb 或者 .rpm 安装包安装的 mongod 进程默认的 bind_ip127.0.0.1

  • --unixSocketPrefix <path> 默认 /tmp。仅用于基于 Unix 的系统。指定保存进程 socket 文件的路径。mongod 进程会创建并监听 unix 套接字,除非以下几个条件任意一个满足:

    • net.unixDomainSocket.enabled 被设置为 false
    • 其他几个条件同 --nounixsocket 的几个条件。
  • --filePermissions <path> 默认 0700。指定 UNIX 域套接字的权限。仅用于 UNIX 类系统。

  • --fork 启用在后台运行 mongod 进程的守护进程模式。 默认情况下,mongod 不作为守护进程运行:通常,您将 mongod 作为守护进程运行,通过使用 --fork 或使用处理守护进程的控制进程(例如,与 upstartsystemd 一样)。

使用 --fork 选项要求您使用以下其中一项配置 mongod 的日志输出:--logpath--syslog

--fork 选项不支持 windows。

  • --auth 启用授权以控制用户对数据库资源和操作的访问。 启用授权后,MongoDB 要求所有客户端首先对自己进行身份验证,以确定客户端的访问权限。通过 mongo shell 配置用户。 如果不存在用户,localhost 将可以继续访问数据库,直到您创建第一个用户。

  • --noauth 禁用授权。当前的默认值。

  • --transitionToAuth 3.4 新增。允许 mongod 接受并创建与部署中的其他 mongodmongos 实例之间的经过身份验证和未经身份验证的连接。 用于执行副本集或分片集群从无身份验证配置到内部身份验证的滚动转换。 需要指定内部身份验证机制,例如 --keyFile

例如,如果使用密钥文件进行内部身份验证,则 mongod 会使用匹配的密钥文件与部署中的任何 mongodmongos 创建经过身份验证的连接。 如果安全机制不匹配,则 mongod 会使用未经身份验证的连接。

使用 --transitionToAuth 运行的 mongod 不会强制执行用户访问控制。 用户无需任何访问控制检查即可连接到您的部署并执行读取、写入和管理操作。

  • --cpu 强制 mongod 进程每四秒报告一次写锁中 CPU 时间的百分比。

  • --sysinfo 返回诊断系统信息,然后退出。 该信息提供页大小、物理页数和可用物理页数。

  • --noscripting 禁用脚本引擎

  • --notablescan 禁止需要集合扫描的操作。

  • --shutdown --shutdown 选项干净安全地终止 mongod 进程。 使用此选项调用 mongod 时,您必须直接或通过配置文件和 --config 选项设置 --dbpath 选项。仅在 Linux 系统可用。

  • --networkMessageCompressors <string> 指定在 mongod 实例之间数据传输的压缩算法(又或者是在分片之间、复制集之间、mongoshell、支持 OP_COMPRESSED 消息格式的驱动)。在 3.6 和 4.0 版本里面,mongodmongos 使用 snappy 作为默认的网络传输压缩算法。从 4.2 版本开始,mongodmongos 默认支持 snappy,zstd,zlib 三种压缩算法。如果不想使用网络压缩,可以将这个选项设置为 disabled

注意:

如果指定了多个压缩算法,会从通信发起者里面按顺序获取第一个共同的压缩算法(跟接受通信的一方)。比如 mongosh 指定了压缩算法为 zlib,snappy,而 mongod 指定的压缩算法为 snappy,zlib, 那么在 mongoshmongod 之间的通信将会使用 zlib

如果通信双方没有共同的压缩算法,则将不会使用传输压缩。

  • --timeZoneInfo <path> 从中加载时区数据库的完整路径。 如果未提供此选项,则 MongoDB 将使用其内置时区数据库。默认是 /usr/share/zoneinfo

  • --outputConfig 4.2 新增。输出 mongod 实例的配置选项(以 yaml 格式)。

存储选项

  • --storageEngine string 存储引擎,默认 wiredTiger。如果您尝试使用 --dbpath 启动 mongod,其中包含由 --storageEngine 指定的存储引擎以外的存储引擎生成的数据文件,mongod 将拒绝启动。

  • --dbpath 在 Linux 上默认为 /data/db,windows 上为 \data\db。指定 mongod 实例存储数据的目录。如果是使用配置文件,则对应的配置项为 storage.dbPath。在 --dbpath 路径的文件对应的存储引擎必须跟 --storageEngine 一致,否则会启动失败。

  • --directoryperdb 使用单独的目录来存储每个数据库的数据。 目录在 --dbpath 目录下,每个子目录名对应数据库名。

  • --syncdelay <value> 控制多长时间使用 fsync 操作同步变动到磁盘。如果设置为 0,MongoDB 将不会同步数据到磁盘。 不要在生产环境使用这个选项,在几乎所有情况下使用默认设置即可。

  • --upgrade 更新磁盘里的文件格式。

  • --repairmongod 实例上的所有数据库执行修复程序。

  • --journal 启用持久性日志以确保数据文件保持有效和可恢复。 此选项仅在您指定 --dbpath 选项时适用。 mongod 默认启用日志功能。

  • --nojournal 禁用日志功能。

  • --journalCommitInterval <value> mongod 进程允许在日志操作之间的最长时间(以毫秒为单位)。 值的范围可以从 1 到 500 毫秒。 较低的值会增加日志的持久性,但会降低磁盘性能。

WiredTiger 选项

  • --wiredTigerCacheSizeGB <float> 定义 WiredTiger 将用于所有数据的内部缓存的最大大小。 索引构建消耗的内存(请参阅 maxIndexBuildMemoryUsageMegabytes)与 WiredTiger 缓存内存是分开的。

从 3.4 开始,默认的值是 50% of (RAM - 1 GB)256MB 中较大的那一个。避免将其设置为比默认值更大的值。通过 WiredTiger,MongoDB 使用 WiredTiger 内部缓存和文件系统缓存。通过文件系统缓存,MongoDB 自动使用 WiredTiger 缓存或其他进程未使用的所有空闲内存。

--wiredTigerCacheSizeGB 限制了 WiredTiger 内部缓存的大小。 操作系统会将可用的空闲内存用于文件系统缓存,这允许压缩的 MongoDB 数据文件保留在内存中。 此外,操作系统将使用任何空闲 RAM 来缓冲文件系统块和文件系统缓存。

为了容纳额外的 RAM 使用者,您可能需要减少 WiredTiger 内部缓存大小。

默认的值假设了你的系统仅有一个 mongod 实例在运行,如果你需要在一台机器上运行多个 mongod 实例,则需要减少 --wiredTigerCacheSizeGB 配置值。如果运行在容器中,则也需要设置为比容器可用内存更小的值。

  • --wiredTigerMaxCacheOverflowFileSizeGB <float> 4.4 版本废弃。

  • --wiredTigerJournalCompressor <compressor> 默认 snappy。指定压缩 WiredTiger 日志数据的算法。

  • --wiredTigerDirectoryForIndexes 指定这个选项的时候,mongod 会将集合和索引保存到不同目录中。

  • --wiredTigerCollectionBlockCompressor <compressor> 默认 snappy,指定集合的压缩算法。

  • --wiredTigerIndexPrefixCompression <boolean> 默认 true。启用或禁用索引数据的前缀压缩。

复制集选项

  • --replSet <setname> 4.0 新增。配置复制。 指定副本集名称作为此集的参数。 副本集中的所有主机必须具有相同的集名称。有多个复制集的时候,每个复制集的名字必须不一样。

  • --oplogSize <value> 指定复制集 oplog 的最大大小,单位 M。

  • --oplogMinRetentionHours <value> 4.4 新增。指定保留 oplog 条目的最小小时数,其中十进制值表示小时的分数。 例如,值 1.5 表示一小时三十分钟。必须大于等于0。默认 0。就是 oplog 到达最大大小之后,超过这个时间的日志需要被删除。

  • --enableMajorityReadConcern 默认 true。

分片集群选项

  • --configsvr 作为集群的配置服务器启动的时候使用。配置服务器只能读写 configadmin 数据库。默认端口为 27019。默认的 --dbpath/data/configdb,除非另外指定。从 3.4 开始,你必须将配置服务器作为复制集部署。不能和 --shardsvr 同时使用。配置服务器不能作为分片服务器。

  • --configsvrMode <string> 只在 3.2 可用。

  • --shardsvr 作为分片服务器启动。默认端口为 27018。从 3.6 开始,你必须将分片服务器作为复制集部署。

  • --moveParanoia 如果指定,则在块迁移期间,分片会将从该分片迁移的所有文档保存到 --dbpathmoveChunk 目录中。MongoDB 不会自动删除保存在 moveChunk 目录中的数据。

  • --noMoveParanoia 从 3.2 开始,默认使用 --noMoveParanoia。在块迁移期间,分片不保存从分片迁移的文档。

TLS 选项

  • --tlsMode <mode> 启用 TLS 支持。disabled 不使用 TLS。allowTLS 服务器之间不使用 TLS,对于即将到来的连接,可以使用或者不使用 TLS。preferTLS 服务器之间使用 TLS,对于即将到来的连接,可以使用或者不使用 TLS。requireTLS 必须使用 TLS。

  • --tlsCertificateKeyFile <filename> 指定包含了 TLS 证书和 key 的 .pem 文件路径。

  • --tlsCertificateKeyFilePassword <value> 指定解开 .pem key 文件的密码。

  • --clusterAuthMode <option> 默认 keyFile。用于集群认证的认证方式。

  • --tlsClusterFile <filename> 指定包含用于集群或副本集成员身份验证的 x.509 证书密钥文件的 .pem 文件。

  • --tlsCertificateSelector <parameter>=<value> 指定证书属性,以便从操作系统的证书存储中选择匹配的证书以用于 TLS。

  • --tlsClusterCertificateSelector <parameter>=<value> 指定证书属性,以便从操作系统的证书存储中选择匹配的证书以用于内部 x.509 成员身份验证。

profiler 选项

  • --profile <level> 0,默认值,不收集任何数据。1 收集慢查询操作。2 记录所有操作。

  • --slowms <integer> 默认 100,单位毫秒。慢查询时间,超过这个时间的操作被视作慢查询。

  • --slowOpSampleRate <double> 默认 1.0。应该分析或记录的慢速操作的比例。 --slowOpSampleRate 接受 0 到 1 之间的值,包括 0 和 1。

审计选项

MongoDB Enterprise 版本可用

  • --auditDestination syslogconsolefile。启用审计并指定审计日志保存的路径。

  • --auditFormat 审计日志的格式,JSON 或者 BSON

  • --auditPath 如果 --auditDestination 指定了 file,则需要通过这个选项指定具体保存路径。

  • --auditFilter 审计日志过滤特定操作。

mongos 分片集群选项

  • --configdb <replicasetName>/<config1>,<config2>... 指定配置服务器和分片集群。

  • --localThreshold 默认 15。mongos 会将读操作发送给复制集中 ping 时间小于这个值的 mongod 实例。

接上一篇,又经历了几天的探索,在昨天解决了一个关键的问题之后(引用了一个被销毁的栈变量导致 SIGSEGV),也没有太多很困难的地方了。然后就先完成了一个 C 版本的协程,再根据 C 的版本完成了 C++ 版本的开发。

从一个月前到现在,在象牙塔里面待久了,是时候回归一下现实了。目前的实现只是实现了简单的协程调度,不过总的来说,最初的目的已经达到了(了解协程实现的原理),所以这个课题到此先告一段落了,该忙一忙别的事情去了。

本文会先从协程的概念说起,然后聊聊其优势,然后再谈具体实现。

协程是什么

我们可以通过跟进程进行一个简单的比较来理解协程是什么。

  • 基本概念

如果说进程是一个运行中的程序,那么协程就可以看作是进程内一个运行中的子程序(em...你可以简单理解为函数)。计算机可以同时运行多个进程,而在一个进程内,同样可以运行多个协程。

  • 运行状态

我们的进程会有几种状态:运行、阻塞等,当进程阻塞的时候,操作系统会切换另外一个不阻塞的进程来运行。同样协程也可以有不同的状态,当我们在一个协程内因为某些原因不能继续执行下去的时候,可以将当前运行的协程标记为类似 “阻塞” 的状态,然后通过自己实现的一个调度器来切换到另外一个协程来执行。

  • 终止

进程的终止会导致进程退出,而协程退出的时候,我们可以自行决定接下来是执行另外一个协程还是直接退出进程(也就是说,是由我们自己决定的)。

通过这简单的几点比较,我们可以得出关于协程的几个关键点: 1. 我们可以在进程内并发运行多个协程。 2. 协程的调度是我们自己实现的(相对进程的调度由操作系统实现,实际使用中都是封装好的,并不需要开发者自行实现)。 3. 一个进程的生命周期里面,可能可能会经历多个协程的生命周期。

协程的优势

正如上面说的,进程有阻塞的状态,进程阻塞的时候,操作系统会调度另外一个就绪的进程来运行。那如果我们的进程内需要进行多个阻塞的操作,那我们这个进程的很多时间就浪费在等待阻塞操作返回上了。为了应对这种情况,unix 下有一套非阻塞的 io 操作,我们可以使用 select、poll、epoll 等来同时监听多个文件句柄的状态,当某一个文件句柄可读或者可写的时候,我们就处理跟这个文件句柄相关的操作。而当当前正在处理的文件句柄因为某些条件而不能继续进行下去的时候,就继续看有没有其他就绪的文件句柄,有则拿出来处理。

而协程可以将这个过程封装一下,每个协程用来关联一个或者文件句柄,当对应的文件句柄就绪的时候,就将进程的上下文切换为对应协程的上下文,这样一来,我们就不需要等待一个阻塞操作执行完之后再去执行下一个阻塞操作,现在这些阻塞操作是并发发生的了。

讲个简单的例子,比如,我们的进程里面需要发送两个消息给服务器 server 并且等待 server 返回(假设一个来回要 1s),而这两个消息 A 和 B 是没有顺序要求的,按以往阻塞的做法,我们需要先将 A 发出去,等待返回,这个过程耗时 1s,然后再将 B 发出去,等待返回,又耗时 1s,整个过程我们就需要花费 2s。

而如果我们在将 A 发出去之后,将进程上下文切换到 B,将 B 也发出去,然后同时等待 A 跟 B 结果的返回,最终返回的时候,我们实际上只需要等待大约 1s,这样我们就节省了 50% 的时间。

这也是协程的一个典型用途,io 阻塞的时候切换到另一个协程,从而节省整个进程在 io 上的等待时间。

协程实现的核心

如前面所说,我们的协程是一个进程内可以有多个协程,而且可以在不同的协程之间进行切换。进程的切换的时候,本质上是不同进程上下文换入换出 CPU 的过程,同样的,协程的切换也是不同的上下文换入换出 CPU 的过程。

但与进程不一样的时候,协程相关的上下文是用户态的上下文,而不是内核态的上下文。

协程调度的核心,或者说本质就是,在进程中实现不同用户态上下文的切换。

底层核心实现都是汇编实现的(因为只有汇编可以直接操作寄存器和堆栈),切换的时候,会将当前寄存器以及堆栈指针等记录下来,将另外一个上下文相关的寄存器以及堆栈指针等还原。

具体实现

总的来说,实现一个协程需要解决以下一些问题: 1. 如何区分协程环境跟非协程环境?因为协程环境下可以使用 yield 来主动让出 CPU 使用权 2. 一个协程需要包含什么信息? 3. 如何创建一个协程? 4. 如何实现协程的切换? 5. 协程退出的话,怎么在下一次调度的时候不再调度这一个已经结束的协程?

问题1,无需进行协程切换的时候,我们的代码依然是以往的执行流程,该阻塞的时候,整个进程阻塞。所以为了区分开这两种情况,我们在执行协程之前,先构建一个协程的上下文,在这个上下文里面来执行我们的协程,然后当有协程产生阻塞操作的时候,在这个上下文里面切换到另一个就绪的协程来执行。

下面的代码创建了一个调度器,然后往里面加入不同的协程,最后,协程调度器开始执行,这个时候就切换到了协程环境的上下文了。

1
2
3
4
5
6
7
8
9
// 创建调度器
scheduler *s = new scheduler;

// 创建协程
s->add(routine1);
s->add(routine2);

// 开始执行协程
scheduler::run();

问题2,我们在看操作系统相关的书籍或者教材啥的可能会了解到,操作系统在进行进程切换的时候,实际上这种操作准确地说是上下文切换,从一个进程的上下文切换到另外一个进程的上下文(上下文包含了当前的寄存器信息,堆栈信息等)。

对于协程来说,如果我们想实现协程切换,我们同样需要在切换的时候保留当前协程的上下文,以便在后续可以恢复到当前这个上下文。保存完当前上下文之后,将上下文切换为另外一个就绪的协程的上下文即可运行另外一个协程了。

而协程上下文,我们可以使用 ucontext_t 来保存,这是一个用户态的上下文结构体。同时为了标识不同的协程,我们可以给每一个协程一个自增 id,所以协程的数据结构定义如下:

1
2
3
4
5
class coroutine {
public:
int id;
ucontext_t *ctx;
};

问题3,从上一个问题我们知道了我们需要一个 ucontext_t 类型的变量来存储上下文相关的信息,而这个变量的初始化我们可以使用 linux 下提供的 getcontextmakecontext 来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
auto *c = new coroutine;
auto *co_ctx = new ucontext_t();

// 初始化上下文
getcontext(co_ctx);

char *stack = new char[STACK_SIZE];
co_ctx->uc_stack.ss_sp = stack;
co_ctx->uc_stack.ss_size = sizeof(stack);

// 指定协程结束后应该跳到的上下文
co_ctx->uc_link = done_ctx();

// 初始化 func 的上下文
makecontext(co_ctx, func, 0);

c->id = id++;
c->ctx = co_ctx;

问题4,在协程 yield 的时候,我们做了一个操作,实现从协程上下文切换到调度器的上下文,使用 swapcontext 函数实现:

1
swapcontext(s->current.ctx, &s->ctx);

但是我们要的效果是,从一个阻塞的协程切换到另外一个就绪的协程,我们只是切换回调度器的上下文是还不够的,但是我们的调度器是可以知道哪些协程是就绪的,所以真正切换到另外一个协程的操作我们是在调度器里面实现的:

下面这个函数只是做了一个简单的遍历,但实际使用中的协程应该还有一个状态来标识是否就绪,协程就绪的时候才切换到对应的上下文,否则我们切换过去什么也做不了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void scheduler::run() {
while (true) {
if (!s->coroutines.empty()) {
for (auto &pair : s->coroutines) {
// 恢复协程上下文,目前的实现缺少状态判断
resume(pair.second);
}

// 从调度器移除已经结束的协程
for (auto i: s->removing) {
s->coroutines.erase(i);
}
s->removing.clear();
} else {
break;
}
}
}

问题5,在我们创建上下文的时候,是可以指定一个协程退出后要使用的上下文的,在我目前的实现里,指向了另外一个上下文,在那个上下文里面执行了下面这个函数:

1
2
3
4
5
6
void done()
{
printf("co %d done.\n", s->current.id);
scheduler::del(s->current);
setcontext(&s->ctx);
}

这里主要做了两个操作,一个是从调度器移除这个协程,这样在下次调度的时候就不会再调度到这个已经结束的协程了,另外一个操作是切换回到调度器的上下文,这样调度器可以继续调度那些未完成的协程。

简单使用及调度流程

main.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include "scheduler.h"

void routine1() {
printf("routine1 running, co: %d\n", s->current.id);
yield();
printf("routine1 returning, co: %d\n", s->current.id);
}

void routine2() {
printf("routine2 running, co: %d\n", s->current.id);
yield();
printf("routine2 returning, co: %d\n", s->current.id);
}

int main()
{
s->add(routine1);
s->add(routine2);

scheduler::run();

printf("main exiting\n");

return 0;
}

输出

1
2
3
4
5
6
7
routine2 running, co: 1
routine1 running, co: 0
routine2 returning, co: 1
co 1 done.
routine1 returning, co: 0
co 0 done.
main exiting

下面是整个程序的执行流程,里面使用了不同颜色标注了不同的上下文:

cpp-coroutine.drawio.png

这里可能会比较有序,因为我们的协程没有任何状态标识,所以只是按顺序调度了一遍。

调度器相关完整代码

下面的实现存在的问题: 1. scheduler 是一个全局变量,在使用上存在较大问题 2. 每一个协程都关联了一个退出协程的上下文,这种开销似乎有点不必要 3. 协程里面缺少状态,而这个状态跟实际使用场景相关,应该是在实际使用中必要的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
#ifndef CPP_COROUTINE_SCHEDULER_C
#define CPP_COROUTINE_SCHEDULER_C

#include "scheduler.h"

// 创建一个新的调度器
scheduler *s = new scheduler;

// 协程退出时候执行的函数
void done()
{
printf("co %d done.\n", s->current.id);
scheduler::del(s->current);
setcontext(&s->ctx);
}

// 创建协程退出时候需要切换到的上下文
ucontext_t *done_ctx()
{
auto *ctx = new ucontext_t();

getcontext(ctx);

char *stack = new char[STACK_SIZE];
ctx->uc_stack.ss_sp = stack;
ctx->uc_stack.ss_size = sizeof(stack);

makecontext(ctx, done, 0);

return ctx;
}

// 往调度器里面添加协程
void scheduler::add(coroutine_func func) {
auto *c = new coroutine;
auto *co_ctx = new ucontext_t();

getcontext(co_ctx);

char *stack = new char[STACK_SIZE];
co_ctx->uc_stack.ss_sp = stack;
co_ctx->uc_stack.ss_size = sizeof(stack);

co_ctx->uc_link = done_ctx();

makecontext(co_ctx, func, 0);

c->id = id++;
c->ctx = co_ctx;

this->coroutines.emplace(c->id, *c);
}

// 协程让出 CPU 使用权,将上下文切换回调度器的上下文
void scheduler::yield() {
swapcontext(s->current.ctx, &s->ctx);
}

// 恢复 co 协程的执行,保存当前上下文到调度器上下文变量
void scheduler::resume(coroutine co) {
s->current = co;
swapcontext(&s->ctx, co.ctx);
}

// 标记需要移除的协程(在一次调度结束之后才会移除,如果在循环 unordered_map 的过程进行移除,可能会导致一些异常的行为产生)
void scheduler::del(coroutine co) {
s->removing.push_back(co.id);
}

// 协程调度代码
void scheduler::run() {
while (true) {
if (!s->coroutines.empty()) {
for (auto &pair : s->coroutines) {
resume(pair.second);
}

// Remove finished coroutines.
for (auto i: s->removing) {
s->coroutines.erase(i);
}
s->removing.clear();
} else {
break;
}
}
}

// 协程 yield 的 wrapper 函数
void yield()
{
scheduler::yield();
}

#endif //CPP_COROUTINE_SCHEDULER_C

全部代码见:https://github.com/eleven26/cpp-coroutine

刚刚看了一下 swoole 的协程实现,使用的是 C++ boost 库里面的 jump_fcontext,而这个 jump_fcontext 是汇编实现。也有使用到了 swapcontext相关的几个函数,具体使用哪个是根据不同编译环境决定的。

但核心要解决的问题都是:如何在不同的用户态上下文之间切换。

后记

这个实现算是一个比较粗糙的实现,不过也实现了一个简单的封装,但距离生产可用的协程实现还差亿点点。从上面也可以看出,其中需要考量的问题其实并不少,但是对于协程的探索到此先告一段落吧。

好吧,我承认有点标题党了。写这个其实主要是记录一下近期对于实现协程的一些实践以及遇到的一些问题(em... 还没有实现),这里的近期是指9月21日至今。过去的这段里,一直在尝试着自己实现一个协程,事实证明,有些事的确就是你想象中的那么难(也许比你想象中还要困难,不过恰巧说明它值得去做)。

故事的开始

故事还得从几个星期前说起,那几天心血来潮,想着自己去实现一个协程,然后顺便把这个过程记录下来。按照自己的猜测,协程切换的机制应该是非阻塞 io 加上操作系统提供的让出 cpu 使用权的一个特性(这个时候还不知道具体是什么特性,但是基本可以肯定是这样)。开始实现的时候,刚好看到 swoole 官方文档里面提到 swoole 2.0 实现协程使用的是 setjmp longjmp

swoole-setjmp

这个时候,自以为实现协程的两大技术难题已经有解决方案了,便开始我的公众号文章的编写,一边写文章一边去写代码,目的就是为了将自己实现的这个过程的一些想法,整个的思考过程记录下来,这样也许别人也可以更好地理解我所写的东西,同时也可以更容易理解协程的实现机制。

setjmp 和 longjmp

在此之前,有必要先说一下 setjmplongjmp 是个什么东西,先看看 linux 官方文档的描述:

The functions described on this page are used for performing "nonlocal gotos": transferring execution from one function to a predetermined location in another function. The setjmp() function dynamically establishes the target to which control will later be transferred, and longjmp() performs the transfer of execution.

setjmplongjmp 示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <stdio.h>
#include <setjmp.h>

jmp_buf bufferA, bufferB;

void routineB(); // forward declaration

void routineA()
{
int r ;

printf("(A1)\n");

r = setjmp(bufferA);
if (r == 0) routineB();

printf("(A2) r=%d\n",r);

r = setjmp(bufferA);
if (r == 0) longjmp(bufferB, 20001);

printf("(A3) r=%d\n",r);

r = setjmp(bufferA);
if (r == 0) longjmp(bufferB, 20002);

printf("(A4) r=%d\n",r);
}

void routineB()
{
int r;

printf("(B1)\n");

r = setjmp(bufferB);
if (r == 0) longjmp(bufferA, 10001);

printf("(B2) r=%d\n", r);

r = setjmp(bufferB);
if (r == 0) longjmp(bufferA, 10002);

printf("(B3) r=%d\n", r);

r = setjmp(bufferB);
if (r == 0) longjmp(bufferA, 10003);
}


int main(int argc, char **argv)
{
routineA();
return 0;
}

输出:

1
2
3
4
5
6
7
(A1)
(B1)
(A2) r=10001
(B2) r=20001
(A3) r=10002
(B3) r=20002
(A4) r=10003

很直观的效果就是,longjmp 实现在在两个函数之间来回跳转。

setjmp

简单来说,就是操作系统提供了这两个函数供开发者实现函数间的 goto 功能,也就是它的功能就是让你可以在一个函数里面可以 goto 到另一个函数内部。这种想法是挺好的,不过 setjmplongjmp 本身的实现有挺多问题,下面会细说。

事实证明,我有点低估了使用 setjmplongjmp 来实现协程的难度,虽然使用 setjmp longjmp 的一些 demo 可以很容易地跑起来,可是当我将这两个函数用于实现自己想法的时候,出现了各种各样的意外情况。比如,不能使用 wrapper 的方式来封装这两个函数,只能直接调用这两个函数才能正常运行,可是这显然不是我们想要的结果,谁也不想为了追求那么一些性能来将代码写得复杂无比。因为相比将代码写复杂,直接换一门高性能的语言来的更靠谱一些。比如换 go 啥的或许会更香。

为了了解为什么自己代码出现了这么多奇奇怪怪的异常,只有去看它们的实现了,正所谓源码之下了无秘密。看了它们的源码发现了一个问题,它们的实现只是保存了 callee-saved 寄存器,以及当前栈指针 bp(base pointer) 和 sp(stack pointer),以及 ip(instruction pointer) 及返回地址。而 ip 和返回地址是由 call 指令隐式写入栈的(这里参考一下下面的函数调用规则)。忘了说了,它们的实现是汇编实现,因为只有汇编才能直接操作寄存器和堆栈。

函数调用规则

这里说的内容是汇编层面的操作,为了使得编译器编译出来的动态连接库等文件里面提供的函数可以相互调用,操作系统有一个叫做 abi(application binary interface)的东西,也就是一个规范,里面定义了一个关键的内容就是,函数调用的时候,怎么保存参数、怎么返回结果,返回之后怎么还原之前的上下文等。

我错了,这东西几句话说不清楚。随便说几句吧,详细可以搜索 calling convention 以及 abi。在函数执行的时候,会有一个栈空间来保存传递给函数的参数,同时函数内的局部变量也会占用栈空间,在一个函数调用另外一个函数的时候(用户态的调用是 call 指令,当然指的是汇编指令,系统调用是 syscall 指令),这里说的是 call 指令,会先将函数调用的下一条指令的地址入栈,然后再跳转到被调用的函数处执行(call 指令隐式修改了 ip)。

stack

图片来源:https://flint.cs.yale.edu/cs421/papers/x86-asm/asm.html

当然这个链接不只有这个图,它是 x86 汇编一个大概的指南,yale edu 出品。

先说一下这个图(函数栈示意图): 1. 在 x86 汇编里面,栈地址是递减的,也就是说入栈的时候,栈地址需要减去对应的字节数。 2. 调用函数的时候,会在栈里面先保存返回地址 3. 再保存 ebp(e 是寄存器前缀,e 前缀代表的是 32 位寄存器),也就是 base pointer,只不过用了一个 32bit 的寄存器来保存,如果是 80x86 就没有 e 前缀了 4. 为被调用函数的局部变量开辟空间 5. 保存被调用函数的参数

在被调函数里面,有一个操作是:pushq %rbp,这个操作是记录上一个栈的 bp,目的是为了在这个被调用函数执行完毕的时候,恢复之前的 bp。然后将当前的 sp 设置为被调函数的 bp,这个时候一个新的栈出现了,不过这个叫做栈帧,这个栈帧在函数调用的时候产生,函数调用结束的时候销毁(不过这里的销毁并不是什么特别的操作,只是将 sp 移动了),并且在函数结束的时候将之前的 bp 恢复,然后返回到之前的函数调用处。

说完了 bpsp,函数调用还有另外一个关键的地方是,calling convention 里面约定了一部分寄存器是 callee-saved 寄存器,这部分寄存器是被调用者保存的。也需要被调用函数保存。(所以 setjmplongjmp 实现里面包含了这些操作。)

说了这么多,其实关键在于 bpsp,如果我们想通过 wrapper 的方式来调用 setjmp,那在 setjmp 的时候,bpsp 实际上是当前 wrapper 函数的栈帧,然后当我们使用 longjmp 来实现函数间 goto 的时候,就只能恢复到 setjmp wrapper 函数的栈帧,以及那时候的 ip(instruction pointer)。

问题来了,在我们使用 setjmp wrapper 函数的时候,调用完的时候,setjmp 时的栈帧已经销毁了,当我们在后面再去使用 longjmp 来尝试跳转到 setjmp 时候的地方就肯定有问题了。当然 ip 可能可以正常设置,但是 bpsp 肯定是完全不对的。

当然除了这个,setjmplongjmp 还有个问题就是,不会保存浮点数寄存器等。也就是如果使用 setjmplongjmp 我们做浮点运算的相关数据会丢失。

makecontext 登场

在这个时候,stackoverflow 上的一个答案给了我新的方向,上面提到,我们可以使用 makecontext 来实现用户级的上下文切换,这不正是我想要的结果吗。然后这个时候有个念头冒了出来,swoole 该不会也是用这个来实现的吧,上 github 搜了一下 swoole 源码,协程里面的确用到了 makecontext,也就是说 swoole 也是用这个 makecontext 以及相关其他函数实现的了。

至此为止总算有了一个突破性的进展了。到这里也许有的人会说,为什么不直接去看 swoole 的实现,这有点像说,你怎么不去抄答案。毕竟我的目的不是为了实现协程这个功能本身,而是想通过实现它来了解协程整个实现的机制,毕竟作为一项对 php 产生了深远影响的技术,了解它本身可以让我们更好地去使用它,了解它的长处、劣势等,甚至,当我们意识到其局限性的时候,我们也会有对应的解决方案。比如替代方案等。又或者了解它本身,协助我们解决实际使用中遇到的各种问题。

在得知 swoole 的实现是使用了 makecontext 之后,又开始了新一轮的探索,还是依照之前的想法来实现。将之前使用 setjmplongjmp 的地方使用 ucontext 相关的函数来代替。demo 代码写完,开始运行,毫无意外报错了,还是 sigsegv,我直接好家伙,又是这种无法直接从代码找到错误的报错。难怪需要 gdb 这种这么高级的 debugger,有些 bug 实在是无法通过简单的观测就可以得到答案,还必须深入 CPU 和内存内部才能勉强找到一点有用的信息。

这个问题没有办法从代码本身找到答案,因为代码好像没有啥问题(或者说我不知道有什么问题,因为用的 C++ 实现,C++ 本身也带来了一定的复杂度),只有先去看看那几个函数是怎么实现的了。getcontextmakecontext 还好,都是一些常规的操作,比如保存通用寄存器、保存浮点数寄存器、保存当前系统信号等。而 swapcontext 着实把我看懵了,又整出什么 shadow stack 这些新概念。使用 shadow stack 的代码看不太懂,不使用 shadow stack 的代码看起来跟 longjmp 有点类似,恢复寄存器,bpsp 等。

先歇一会吧,明天继续

陆陆续续又看了一个星期了,依旧没有找到答案,今晚刚好从老家回来又折腾了一晚上,依旧很多问题,改了几版,都有 bug,还原回国庆前的版本,放这里纪念一下吧:

source

明天起来用 C 来看看能不能实现一个简单的版本,调用麻烦一点也行,先去掉 C++ 本身复杂性的影响。再不行就参考一下答案了,比如 swoole,或者云风10年前的一个实现~

困了困了,放假就应该好好休息,睡了~