核心选项

  • -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年前的一个实现~

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

环境:Ubuntu 20.04

代码很简单,在 main 函数里面定义一个长度为 1024K 的数组(一个 int 占 4 个字节):

1
2
3
4
int main()
{
int a[1024 * 1024 / 4] = {};
}

然后编译执行:

1
2
3
4
$ gcc main.cpp 
$ ./a.out

Segmentation fault (core dumped)

原因

为了保护操作系统,防止无限递归这种错误导致内存被耗光,系统对程序的栈大小有一个默认值的限制。

参考:https://stackoverflow.com/questions/1825964/c-c-maximum-stack-size-of-program

实际的限制可以使用 ulimit -s 来查看。

在我本机上,其实是先执行了 ulimit -s 1024 将栈大小限制为 1024K 了,然后我的数组就用了 1024K,另外还有函数调用本身需要保存一些信息(比如返回地址等),所以就超过了 1024K,也就是超出了栈的大小了。

解决方法

有一个解决方法是,将 ulimit -s 的值增大,比如,设置成 8M:

1
ulimit -s 8192

然后重新执行 ./a.out

1
$ ./a.out

再次执行就没有报错了,因为我的栈空间足够大了。

启发

之前使用 swoole 的时候,有时候有 SIGSEGV 这种错误,现在看来或许是有什么地方超出了栈的大小了。

在 linux 下,glibc 提供了几个函数来给用户实现用户态的上下文切换:

  • getcontext:初始化一个上下文参数
  • setcontext:根据参数还原上下文
  • makecontext:创建一个新的上下文
  • swapcontext:上下文切换

getcontext 和 setcontext

详细文档:https://linux.die.net/man/3/getcontext

1
2
3
4
#include <ucontext.h>

int getcontext(ucontext_t *ucp);
int setcontext(const ucontext_t *ucp);
  • getcontext: 保存当前上下文到第一个参数
  • setcontext: 设置当前上下文为第一个参数(成功调用的时候不会返回)。
    • 如果第一个参数是通过 getcontext 获取的,则调用之后继续往下执行,
    • 如果是通过 makecontext 创建的,程序会调用 makecontext 的第二个参数指向的函数,
    • 当函数执行完毕,转到 uc_link 指向的上下文,如果 uc_linknull,则线程退出。

makecontext

详细文档:https://man7.org/linux/man-pages/man3/swapcontext.3.html

1
2
3
4
5
#include <ucontext.h>

void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...);
int swapcontext(ucontext_t *restrict oucp,
const ucontext_t *restrict ucp);
  • makecontext: 修改第一个参数为 getcontext 之后的 ucontext_t,如 getcontext(&uctx); makecontext(&uctx);
  • swapcontext: 保存当前上下文到第一个参数,然后切换到第二个参数对应的上下文

ucontext_t.uc_link 在当前上下文结束后的上下文

getcontext 和 setcontext 成功的时候返回 0,失败的时候返回 -1。

注意事项

  • OSX 下 ucontext_t 的栈大小要大于等于 32Kb,否则 makecontext 调用无效。
  • context 对应的函数里面需要用到栈的时候,是线程共享的栈,而不是 ucontext_t.uc_stack 指定的栈。所以给 ucontext_t.uc_stack 设置初始化的栈的时候,不用考虑 context 对应函数需要占用多少栈空间。
0%