属性 | HTTP/3 | HTTP/2 | HTTPS | HTTP1.1/1.0/0.9 |
---|---|---|---|---|
协议属性 | 应用层 HTTP 需要 HTTP/2 | 需要 HTTPS | 需要 HTTP1 | - |
底层协议 | UDP QUIC | TCP | TCP | TCP |
二进制传输 | 是 | 是 | 否 | 否 |
流式 | 管理,简化了流管理。 | 需要服务端客户端协商 | ||
支持多工 | 是 | 是 | 1.1 支持客户端并行,但不支持服务端 其他否 | |
如何终止传输 | 可以单独放弃某些传输 | 可以单独放弃某些传输 | TCP 断开 | TCP 断开 |
是否有状态 | 无,但是可以自行管里 | 无,但是头部一已经压缩了,可以用标号代替 cookie 等 | ||
HTTP 协议是互联网的基础协议,也是网页开发的必备知识,最新版本 HTTP/2 更是让它成为技术热点。
本文介绍 HTTP 协议的历史演变和设计思路。
HTTP 是基于 TCP/IP 协议的应用层协议。它不涉及数据包(packet)传输,主要规定了客户端和服务器之间的通信格式,默认使用 80 端口。
最早版本是 1991 年发布的 0.9 版。该版本极其简单,只有一个命令 GET。
1 | GET /index.html |
上面命令表示,TCP 连接(connection)建立后,客户端向服务器请求(request)网页 index.html。
协议规定,服务器只能回应 HTML 格式的字符串,不能回应别的格式。
1 | <html> |
服务器发送完毕,就关闭 TCP 连接。
1996 年 5 月,HTTP/1.0 版本发布,内容大大增加。
首先,任何格式的内容都可以发送。这使得互联网不仅可以传输文字,还能传输图像、视频、二进制文件。这为互联网的大发展奠定了基础。
其次,除了 GET 命令,还引入了 POST 命令和 HEAD 命令,丰富了浏览器与服务器的互动手段。
再次,HTTP 请求和回应的格式也变了。除了数据部分,每次通信都必须包括头信息(HTTP header),用来描述一些元数据。
其他的新增功能还包括状态码(status code)、多字符集支持、多部分发送(multi-part type)、权限(authorization)、缓存(cache)、内容编码(content encoding)等。
下面是一个 1.0 版的 HTTP 请求的例子。
1 | GET / HTTP/1.0 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) |
可以看到,这个格式与 0.9 版有很大变化。
第一行是请求命令,必须在尾部添加协议版本(HTTP/1.0)。后面就是多行头信息,描述客户端的情况。
服务器的回应如下。
1 |
|
回应的格式是”头信息 + 一个空行(\r\n) + 数据”。其中,第一行是”协议版本 + 状态码(status code) + 状态描述”。
关于字符的编码,1.0 版规定,头信息必须是 ASCII 码,后面的数据可以是任何格式。因此,服务器回应的时候,必须告诉客户端,数据是什么格式,这就是 Content-Type 字段的作用。
下面是一些常见的 Content-Type 字段的值。
1 | text/plain |
这些数据类型总称为 MIME type,每个值包括一级类型和二级类型,之间用斜杠分隔。
除了预定义的类型,厂商也可以自定义类型。
1 | application / vnd.debian.binary - package |
上面的类型表明,发送的是 Debian 系统的二进制数据包。
MIME type 还可以在尾部使用分号,添加参数。
1 | Content-Type: text/html; charset=utf-8 |
上面的类型表明,发送的是网页,而且编码是 UTF-8。
客户端请求的时候,可以使用 Accept 字段声明自己可以接受哪些数据格式。
1 | Accept: */* |
上面代码中,客户端声明自己可以接受任何格式的数据。
MIME type 不仅用在 HTTP 协议,还可以用在其他地方,比如 HTML 网页。
1 | <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> |
由于发送的数据可以是任何格式,因此可以把数据压缩后再发送。Content-Encoding 字段说明数据的压缩方法。
1 | Content-Encoding: gzip |
客户端在请求时,用 Accept-Encoding 字段说明自己可以接受哪些压缩方法。
1 | Accept-Encoding: gzip, deflate |
HTTP/1.0 版的主要缺点是,每个 TCP 连接只能发送一个请求。发送数据完毕,连接就关闭,如果还要请求其他资源,就必须再新建一个连接。
TCP 连接的新建成本很高,因为需要客户端和服务器三次握手,并且开始时发送速率较慢(slow start)。所以,HTTP 1.0 版本的性能比较差。随着网页加载的外部资源越来越多,这个问题就愈发突出了。
为了解决这个问题,有些浏览器在请求时,用了一个非标准的 Connection 字段。
1 | Connection: keep-alive |
这个字段要求服务器不要关闭 TCP 连接,以便其他请求复用。服务器同样回应这个字段。
1 | Connection: keep-alive |
一个可以复用的 TCP 连接就建立了,直到客户端或服务器主动关闭连接。但是,这不是标准字段,不同实现的行为可能不一致,因此不是根本的解决办法。
1997 年 1 月,HTTP/1.1 版本发布,只比 1.0 版本晚了半年。它进一步完善了 HTTP 协议,一直用到了 20 年后的今天,直到现在还是最流行的版本。
1.1 版的最大变化,就是引入了持久连接(persistent connection),即 TCP 连接默认不关闭,可以被多个请求复用,不用声明 Connection: keep-alive。
客户端和服务器发现对方一段时间没有活动,就可以主动关闭连接。不过,规范的做法是,客户端在最后一个请求时,发送 Connection: close,明确要求服务器关闭 TCP 连接。
1 | Connection: close |
目前,对于同一个域名,大多数浏览器允许同时建立 6 个持久连接。
1.1 版还引入了管道机制(pipelining),即在同一个 TCP 连接里面,客户端可以同时发送多个请求。这样就进一步改进了 HTTP 协议的效率。
举例来说,客户端需要请求两个资源。以前的做法是,在同一个 TCP 连接里面,先发送 A 请求,然后等待服务器做出回应,收到后再发出 B 请求。管道机制则是允许浏览器同时发出 A 请求和 B 请求,但是服务器还是按照顺序,先回应 A 请求,完成后再回应 B 请求。
一个 TCP 连接现在可以传送多个回应,势必就要有一种机制,区分数据包是属于哪一个回应的。这就是 Content-length 字段的作用,声明本次回应的数据长度。
1 | Content-Length: 3495 |
上面代码告诉浏览器,本次回应的长度是 3495 个字节,后面的字节就属于下一个回应了。
在 1.0 版中,Content-Length 字段不是必需的,因为浏览器发现服务器关闭了 TCP 连接,就表明收到的数据包已经全了。
使用 Content-Length 字段的前提条件是,服务器发送回应之前,必须知道回应的数据长度。
对于一些很耗时的动态操作来说,这意味着,服务器要等到所有操作完成,才能发送数据,显然这样的效率不高。更好的处理方法是,产生一块数据,就发送一块,采用”流模式”(stream)取代”缓存模式”(buffer)。
因此,1.1 版规定可以不使用 Content-Length 字段,而使用“分块传输编码”(chunked transfer encoding)。
分块传输编码(Chunked transfer encoding)是超文本传输协议(HTTP)中的一种数据传输机制,允许HTTP由网页服务器发送给客户端应用( 通常是网页浏览器)的数据可以分成多个部分。分块传输编码只在 HTTP 协议 1.1 版本(HTTP/1.1)中提供。
通常,HTTP 应答消息中发送的数据是整个发送的,Content-Length 消息头字段表示数据的长度。数据的长度很重要,因为客户端需要知道哪里是应答消息的结束,以及后续应答消息的开始。然而,使用分块传输编码,数据分解成一系列数据块,并以一个或多个块发送,这样服务器可以发送数据而不需要预先知道发送内容的总大小。通常数据块的大小是一致的,但也不总是这种情况。
只要请求或回应的头信息有 Transfer-Encoding 字段,就表明回应将由数量未定的数据块组成。
1 | Transfer-Encoding: chunked |
每个非空的数据块之前,会有一个 16 进制的数值,表示这个块的长度。最后是一个大小为 0 的块,就表示本次回应的数据发送完了。下面是一个例子。
1 | HTTP/1.1 200 OK Content-Type: text/plain Transfer-Encoding: chunked 25 This is |
1.1 版还新增了许多动词方法:PUT、PATCH、HEAD、OPTIONS、DELETE。
另外,客户端请求的头信息新增了 Host 字段,用来指定服务器的域名。
Host: www.example.com
有了 Host 字段,就可以将请求发往同一台服务器上的不同网站,为虚拟主机的兴起打下了基础。
虽然 1.1 版允许复用 TCP 连接,但是同一个 TCP 连接里面,所有的数据通信是按次序进行的。服务器只有处理完一个回应,才会进行下一个回应。要是前面的回应特别慢,后面就会有许多请求排队等着。这称为“队头堵塞”(Head-of-line blocking)。
队头阻塞(英語:Head-of-line blocking,缩写:HOL blocking)在计算机网络的范畴中是一种性能受限的现象。它的原因是一列的第一个数据包(队头)受阻而导致整列数据包受阻。例如它有可能在缓存式输入的交换机中出现,有可能因为传输顺序错乱而出现,亦有可能在 HTTP 流水线中有多个请求的情况下出现。
为了避免这个问题,只有两种方法:一是减少请求数,二是同时多开持久连接。这导致了很多的网页优化技巧,比如合并脚本和样式表、将图片嵌入 CSS 代码、域名分片(domain sharding)等等。如果 HTTP 协议设计得更好一些,这些额外的工作是可以避免的。
2009 年,谷歌公开了自行研发的 SPDY 协议,主要解决 HTTP/1.1 效率不高的问题。
这个协议在 Chrome 浏览器上证明可行以后,就被当作 HTTP/2 的基础,主要特性都在 HTTP/2 之中得到继承。
2015 年,HTTP/2 发布。它不叫 HTTP/2.0,是因为标准委员会不打算再发布子版本了,下一个新版本将是 HTTP/3。
HTTP/1.1 版的头信息肯定是文本(ASCII 编码),数据体可以是文本,也可以是二进制。HTTP/2 则是一个彻底的二进制协议,头信息和数据体都是二进制,并且统称为”帧”(frame):头信息帧和数据帧。
二进制协议的一个好处是,可以定义额外的帧。HTTP/2 定义了近十种帧,为将来的高级应用打好了基础。如果使用文本实现这种功能,解析数据将会变得非常麻烦,二进制解析则方便得多。
HTTP/2 复用 TCP 连接,在一个连接里,客户端和浏览器都可以同时发送多个请求或回应,而且不用按照顺序一一对应,这样就避免了”队头堵塞”。
举例来说,在一个 TCP 连接里面,服务器同时收到了 A 请求和 B 请求,于是先回应 A 请求,结果发现处理过程非常耗时,于是就发送 A 请求已经处理好的部分, 接着回应 B 请求,完成后,再发送 A 请求剩下的部分。
这样双向的、实时的通信,就叫做多工(Multiplexing)。
因为 HTTP/2 的数据包是不按顺序发送的,同一个连接里面连续的数据包,可能属于不同的回应。因此,必须要对数据包做标记,指出它属于哪个回应。
HTTP/2 将每个请求或回应的所有数据包,称为一个数据流(stream)。每个数据流都有一个独一无二的编号。数据包发送的时候,都必须标记数据流 ID,用来区分它属于哪个数据流。另外还规定,客户端发出的数据流,ID 一律为奇数,服务器发出的,ID 为偶数。
数据流发送到一半的时候,客户端和服务器都可以发送信号(RST_STREAM 帧),取消这个数据流。1.1 版取消数据流的唯一方法,就是关闭 TCP 连接。这就是说,HTTP/2 可以取消某一次请求,同时保证 TCP 连接还打开着,可以被其他请求使用。
客户端还可以指定数据流的优先级。优先级越高,服务器就会越早回应。
HTTP 协议不带有状态,每次请求都必须附上所有信息。所以,请求的很多字段都是重复的,比如 Cookie 和 User Agent,一模一样的内容,每次请求都必须附带,这会浪费很多带宽,也影响速度。
HTTP/2 对这一点做了优化,引入了头信息压缩机制(header compression)。一方面,头信息使用 gzip 或 compress 压缩后再发送;另一方面,客户端和服务器同时维护一张头信息表,所有字段都会存入这个表,生成一个索引号,以后就不发送同样字段了,只发送索引号,这样就提高速度了。
HTTP/2 允许服务器未经请求,主动向客户端发送资源,这叫做服务器推送(server push)。
常见场景是客户端请求一个网页,这个网页里面包含很多静态资源。正常情况下,客户端必须收到网页后,解析 HTML 源码,发现有静态资源,再发出静态资源请求。其实,服务器可以预期到客户端请求网页后,很可能会再请求静态资源,所以就主动把这些静态资源随着网页一起发给客户端了。
上节提到 HTTP3 通过更加底层的传输层的优化来提升效率,究竟如何,让我们一起看一下。
通过这个图片,我们可以很清楚的看到,HTTP2 和 HTTP3 的传输层是完全不同的协议,HTTP3 的传输层是 UDP 协议。我们知道 UDP 协议是个不可靠的协议,而 TCP 协议是可靠协议,怎样保证可靠的呢,重传。
在 UDP 协议之上,新增了 QUIC 协议。我的理解是由于 TCP 协议相对于 UDP 协议控制比较复杂耗时,因此针对 HTTP 应用贴身开发了 QUIC 协议代替 TCP 协议中关于可靠、流量控制的部分。
QUIC 协议特性
然后我们看一下 HTTP3 在 QUIC 上有什么变化呢?HTTP3 由 HTTP2 进化,HTTP2 最大的变化就是基于二进制流的传输。那么到 HTTP3,由于 QUIC 已经管理了流,HTTP3 本身就减负了,将流管理下移 QUIC,而本身就直接调用 QUIC 的接口就可以了。
这三者,都用 TCP 的握手协议去理解,都是握手,不同的是握手方式不一样。
@author
字段告诉我们自己是什么人。我们是作者,作者都有读者。实际上,作者有责任与读者做良好沟通。下次你写代码的时候,记得自己是作者,要为评判你工作的读者写代码。switch
语句往往很难。写出只做一件事的 switch
语句也很难。我们总不发避开 switch
语句,不过还是能够确保 switch
都埋藏在较低的抽象层级,而且永远不重复。1 | public class UserValidator { |
副作用就在于对 Session.initialize()
的调用。checkPassword
函数是用来检查密码的。该名称未暗示它会初始化该次会话。当某个误信了函数名的调用者想要检查用户有效性时,就得冒着抹除现有会话数据的风险。这一副作用造成了一次时序性耦合。也就是说,checkPassword
只能在特定时刻调用。
// TODO
形式在源代码中放置要做的工作列表。TODO
是一种程序员认为应该做,但由于某些原因目前还没做的工作。DTO
(Data Transfer Objects)。DTO 是非常有用的结构,尤其是在于数据库通信、或解析套接字传输的消息之类的场景中。checked exception
的代价是违反开闭原则。如果你在方法中抛出可控异常,而 catch 语句在三个层级之上,你就得在 catch 语句和抛出异常处之间的每个方法签名中声明该异常。这意味着对软件中低层级的修改,都将涉及较高层级的签名。最终得到的就是一个从软件最底端贯穿到最高端的修改链。Colletions.emptyList()
方法,该方法返回一个预定义不可变列表,这样编码,就能尽量避免 NullPointerException
的出现,代码也就更整洁了。1 | public String compact(String message) { |
public static final int
老花招。那样做 int 的意义就丧失了,而用 enum 则不然,因为它们隶属于有名称的枚举。SDS(Simple Dynamic Strings, 简单动态字符串)是 Redis 的一种基本数据结构,主要是用于存储字符串和整数。 这篇文章里,我们就来探讨一下 Redis SDS 这种数据结构的底层实现原理。
学习之前,首先我们要明确,Redis 是一个使用 C 语言编写的键值对存储系统。
我们首先考虑一个问题,如何实现一个二进制安全的字符串?
在 C 语言中,\0
表示字符串结束,如果字符串中本身就包含 \0
字符,那么字符串就会在 \0
处被截断,即非二进制安全;若通过使用一个 len 属性,来判断字符串是否结束,就可以保证读写字符串时不受到 \0
的影响,则是二进制安全。同时 len 属性也能保证在 O(1) 时间内获取字符串的长度。
在 Redis 3.2 版本以前,SDS 的结构如下:
1 | struct sdshdr { |
其中,buf 表示数据空间,用于存储字符串;len 表示 buf 中已占用的字节数,也即字符串长度;free 表示 buf 中剩余可用字节数。
字段 len 和 free 各占 4 字节,紧接着存放字符串。
这样做有以下几个好处:
\0
,保证二进制安全。但其实以上的设计是存在一些问题的,对于不同长度的字符串,是否有必要使用 len 和 free 这 2 个 4 字节的变量?4 字节的 len,可表示的字符串长度为 2^32
,而在实际应用中,存放于 Redis 中的字符串往往没有这么长,因此,空间的使用上能否进一步压缩?
那么接下来,我们就来看看最新的 Redis 是如何根据字符串的长度,使用不同的数据结构进行存储的。
在 Redis 3.2 版本之后(v3.2 - v6.0),Redis 将 SDS 划分为 5 种类型:
Redis 增加了一个 flags 字段来标识类型,用一个字节(8 位)来存储。
其中:前 3 位表示字符串的类型;剩余 5 位,可以用来存储长度小于 32 的短字符串。
1 | struct __attribute__ ((__packed__)) sdshdr5 { |
而对于长度大于 31 的字符串,仅仅靠 flags 的后 5 位来存储长度明显是不够的,需要用另外的变量来存储。sdshdr8、sdshdr16、sdshdr32、sdshdr64 的数据结构定义如下,其中 len 表示已使用的长度,alloc 表示总长度,buf 存储实际内容,而 flags 的前 3 位依然存储类型,后 5 位则预留。
1 | struct __attribute__ ((__packed__)) sdshdr8 { |
注意,一般情况下,结构体会按照所有变量大小的最小公倍数做字节对齐,而用 packed
修饰后,结构体则变为 1 字节对齐。这样做的好处有二:一是节省内存,比如 sdshdr32 可节约 3 个字节;二是 SDS 返回给上层的是指向 buf 的指针,此时按 1 字节对齐,所以可在创建 SDS 后,通过 (char*)sh+hdrlen
得到 buf 指针地址,也可以通过 buf[-1]
找到 flags。
以上,Redis 根据字符串长度的不同,选择对应的数据结构进行存储。接下来,我们就来看看 Redis 字符串的相关 API 实现。
1 | sds sdsnewlen(const void *init, size_t initlen) { |
创建 SDS 的大致流程是这样的:首先根据字符串长度计算得到 type,根据 type 计算头部所需长度,然后动态分配内存空间。
注意:
SDS_TYPE_5
被强制转换为 SDS_TYPE_8
(原因是创建空字符串后,内容可能会频繁更新而引发扩容操作,故直接创建为 sdshdr8)+1
操作,因为结束符 \0
会占用一个长度的空间。SDS 提供了两种清空字符串的方法。
一种是通过 s 偏移得到结构体的地址,然后调用 s_free
直接释放内存。
1 | void sdsfree(sds s) { |
另一种是通过重置 len 属性值而达到清空字符串的目的,本质上 buf 并没有被真正清除,新的数据会直接覆盖 buf 中原有的数据,无需申请新的内存空间。
1 | void sdsclear(sds s) { |
SDS 拼接字符串的实现如下:
1 | sds sdscatsds(sds s, const sds t) { |
可以看到 sdscatsds
内部调用的是 sdscatlen
。
而 sdscatlen
内部的实现相对复杂一些,由于拼接字符串可能涉及 SDS 的扩容,因此 sdscatlen
内部调用 sdsMakeRoomFor
对拼接的字符串做检查:若无需扩容,直接返回 s;若需要扩容,则返回扩容好的新字符串 s。
1 | sds sdscatlen(sds s, const void *t, size_t len) { |
SDS 的扩容策略是这样的:
len+addlen < 1MB
的,按新长度的 2 倍扩容;新增后总长度 len+addlen >= 1MB
的,按新长度加上 1MB
扩容。1 | sds sdsMakeRoomFor(sds s, size_t addlen) { |
\0
影响,从而保证二进制安全;sdsMakeRoomFor
函数进行检查,根据不同情况进行扩容。全文完!
▎阅读索引
**
概念
入口
系统调用管理
runtime 中的 SYSCALL
和调度的交互
entersyscall
exitsyscallfast
exitsyscall
entersyscallblock
entersyscallblock_handoff
entersyscall_sysmon
entersyscall_gcwait
总结
**
syscall 有下面几个入口,在 syscall/asm_linux_amd64.s 中。
1func Syscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err syscall.Errno)
2
3func Syscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err syscall.Errno)
4
5func RawSyscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err syscall.Errno)
6
7func RawSyscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err syscall.Errno)
8
这些函数的实现都是汇编,按照 linux 的 syscall 调用规范,我们只要在汇编中把参数依次传入寄存器,并调用 SYSCALL 指令即可进入内核处理逻辑,系统调用执行完毕之后,返回值放在 RAX 中:
Syscall 和 Syscall6 的区别只有传入参数不一样:
1// func Syscall(trap int64, a1, a2, a3 uintptr) (r1, r2, err uintptr);
2TEXT ·Syscall(SB),NOSPLIT,$0-56
3 CALL runtime·entersyscall(SB)
4 MOVQ a1+8(FP), DI
5 MOVQ a2+16(FP), SI
6 MOVQ a3+24(FP), DX
7 MOVQ $0, R10
8 MOVQ $0, R8
9 MOVQ $0, R9
10 MOVQ trap+0(FP), AX // syscall entry
11 SYSCALL
12 // 0xfffffffffffff001 是 linux MAX_ERRNO 取反 转无符号,http://lxr.free-electrons.com/source/include/linux/err.h#L17
13 CMPQ AX, $0xfffffffffffff001
14 JLS ok
15 MOVQ $-1, r1+32(FP)
16 MOVQ $0, r2+40(FP)
17 NEGQ AX
18 MOVQ AX, err+48(FP)
19 CALL runtime·exitsyscall(SB)
20 RET
21ok:
22 MOVQ AX, r1+32(FP)
23 MOVQ DX, r2+40(FP)
24 MOVQ $0, err+48(FP)
25 CALL runtime·exitsyscall(SB)
26 RET
27
28// func Syscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2, err uintptr)
29TEXT ·Syscall6(SB),NOSPLIT,$0-80
30 CALL runtime·entersyscall(SB)
31 MOVQ a1+8(FP), DI
32 MOVQ a2+16(FP), SI
33 MOVQ a3+24(FP), DX
34 MOVQ a4+32(FP), R10
35 MOVQ a5+40(FP), R8
36 MOVQ a6+48(FP), R9
37 MOVQ trap+0(FP), AX // syscall entry
38 SYSCALL
39 CMPQ AX, $0xfffffffffffff001
40 JLS ok6
41 MOVQ $-1, r1+56(FP)
42 MOVQ $0, r2+64(FP)
43 NEGQ AX
44 MOVQ AX, err+72(FP)
45 CALL runtime·exitsyscall(SB)
46 RET
47ok6:
48 MOVQ AX, r1+56(FP)
49 MOVQ DX, r2+64(FP)
50 MOVQ $0, err+72(FP)
51 CALL runtime·exitsyscall(SB)
52 RET
两个函数没什么大区别,为啥不用一个呢?个人猜测,Go 的函数参数都是栈上传入,可能是为了节省一点栈空间。。在正常的 Syscall 操作之前会通知 runtime,接下来我要进行 syscall 操作了 runtime·entersyscall **,退出时会调用 runtime·exitsyscall **。
1// func RawSyscall(trap, a1, a2, a3 uintptr) (r1, r2, err uintptr)
2TEXT ·RawSyscall(SB),NOSPLIT,$0-56
3 MOVQ a1+8(FP), DI
4 MOVQ a2+16(FP), SI
5 MOVQ a3+24(FP), DX
6 MOVQ $0, R10
7 MOVQ $0, R8
8 MOVQ $0, R9
9 MOVQ trap+0(FP), AX // syscall entry
10 SYSCALL
11 CMPQ AX, $0xfffffffffffff001
12 JLS ok1
13 MOVQ $-1, r1+32(FP)
14 MOVQ $0, r2+40(FP)
15 NEGQ AX
16 MOVQ AX, err+48(FP)
17 RET
18ok1:
19 MOVQ AX, r1+32(FP)
20 MOVQ DX, r2+40(FP)
21 MOVQ $0, err+48(FP)
22 RET
23
24// func RawSyscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2, err uintptr)
25TEXT ·RawSyscall6(SB),NOSPLIT,$0-80
26 MOVQ a1+8(FP), DI
27 MOVQ a2+16(FP), SI
28 MOVQ a3+24(FP), DX
29 MOVQ a4+32(FP), R10
30 MOVQ a5+40(FP), R8
31 MOVQ a6+48(FP), R9
32 MOVQ trap+0(FP), AX // syscall entry
33 SYSCALL
34 CMPQ AX, $0xfffffffffffff001
35 JLS ok2
36 MOVQ $-1, r1+56(FP)
37 MOVQ $0, r2+64(FP)
38 NEGQ AX
39 MOVQ AX, err+72(FP)
40 RET
41ok2:
42 MOVQ AX, r1+56(FP)
43 MOVQ DX, r2+64(FP)
44 MOVQ $0, err+72(FP)
45 RET
RawSyscall 和 Syscall 的区别也非常微小,就只是在进入 Syscall 和退出的时候没有通知 runtime,这样 runtime 理论上是没有办法通过调度把这个 g 的 m 的 p 调度走的,所以如果用户代码使用了 RawSyscall 来做一些阻塞的系统调用,是有可能阻塞其它的 g 的,下面是官方开发的原话:
Yes, if you call RawSyscall you may block other goroutines from running. The system monitor may start them up after a while, but I think there are cases where it won’t. I would say that Go programs should always call Syscall. RawSyscall exists to make it slightly more efficient to call system calls that never block, such as getpid. But it’s really an internal mechanism.
1// func gettimeofday(tv *Timeval) (err uintptr)
2TEXT ·gettimeofday(SB),NOSPLIT,$0-16
3 MOVQ tv+0(FP), DI
4 MOVQ $0, SI
5 MOVQ runtime·__vdso_gettimeofday_sym(SB), AX
6 CALL AX
7
8 CMPQ AX, $0xfffffffffffff001
9 JLS ok7
10 NEGQ AX
11 MOVQ AX, err+8(FP)
12 RET
13ok7:
14 MOVQ $0, err+8(FP)
15 RET
先是系统调用的定义文件:
1/syscall/syscall_linux.go
可以把系统调用分为三类:
阻塞系统调用
非阻塞系统调用
wrapped 系统调用
阻塞系统调用会定义成下面这样的形式:
1//sys Madvise(b []byte, advice int) (err error)
非阻塞系统调用:
1//sysnb EpollCreate(size int) (fd int, err error)
然后,根据这些注释,mksyscall.pl 脚本会生成对应的平台的具体实现。mksyscall.pl 是一段 perl 脚本,感兴趣的同学可以自行查看,这里就不再赘述了。
看看阻塞和非阻塞的系统调用的生成结果:
1func Madvise(b []byte, advice int) (err error) {
2 var _p0 unsafe.Pointer
3 if len(b) > 0 {
4 _p0 = unsafe.Pointer(&b[0])
5 } else {
6 _p0 = unsafe.Pointer(&_zero)
7 }
8 _, *, e1 := Syscall(SYS_MADVISE, uintptr(_p0), uintptr(len(b)), uintptr(advice))
9 if e1 != 0 {
10 err = errnoErr(e1)
11 }
12 return
13}
14
15func EpollCreate(size int) (fd int, err error) {
16 r0, *, e1 := RawSyscall(SYS_EPOLL_CREATE, uintptr(size), 0, 0)
17 fd = int(r0)
18 if e1 != 0 {
19 err = errnoErr(e1)
20 }
21 return
22}
显然,标记为 sys 的系统调用使用的是 Syscall 或者 Syscall6,标记为 sysnb 的系统调用使用的是 RawSyscall 或 RawSyscall6。
wrapped 的系统调用是怎么一回事呢?
1func Rename(oldpath string, newpath string) (err error) {
2 return Renameat(_AT_FDCWD, oldpath, _AT_FDCWD, newpath)
3}
可能是觉得系统调用的名字不太好,或者参数太多,我们就简单包装一下。没啥特别的。
除了上面提到的阻塞非阻塞和 wrapped syscall,runtime 中还定义了一些 low-level 的 syscall,这些是不暴露给用户的。
提供给用户的 syscall 库,在使用时,会使 goroutine 和 p 分别进入 Gsyscall 和 Psyscall 状态。但 runtime 自己封装的这些 syscall 无论是否阻塞,都不会调用 entersyscall 和 exitsyscall。虽说是 “low-level” 的 syscall。
不过和暴露给用户的 syscall 本质是一样的。这些代码在 runtime/sys_linux_amd64.s 中,举个具体的例子:
1TEXT runtime·write(SB),NOSPLIT,$0-28
2 MOVQ fd+0(FP), DI
3 MOVQ p+8(FP), SI
4 MOVL n+16(FP), DX
5 MOVL $SYS_write, AX
6 SYSCALL
7 CMPQ AX, $0xfffffffffffff001
8 JLS 2(PC)
9 MOVL $-1, AX
10 MOVL AX, ret+24(FP)
11 RET
12
13TEXT runtime·read(SB),NOSPLIT,$0-28
14 MOVL fd+0(FP), DI
15 MOVQ p+8(FP), SI
16 MOVL n+16(FP), DX
17 MOVL $SYS_read, AX
18 SYSCALL
19 CMPQ AX, $0xfffffffffffff001
20 JLS 2(PC)
21 MOVL $-1, AX
22 MOVL AX, ret+24(FP)
23 RET
下面是所有 runtime 另外定义的 syscall 列表:
1#define SYS_read 0
2#define SYS_write 1
3#define SYS_open 2
4#define SYS_close 3
5#define SYS_mmap 9
6#define SYS_munmap 11
7#define SYS_brk 12
8#define SYS_rt_sigaction 13
9#define SYS_rt_sigprocmask 14
10#define SYS_rt_sigreturn 15
11#define SYS_access 21
12#define SYS_sched_yield 24
13#define SYS_mincore 27
14#define SYS_madvise 28
15#define SYS_setittimer 38
16#define SYS_getpid 39
17#define SYS_socket 41
18#define SYS_connect 42
19#define SYS_clone 56
20#define SYS_exit 60
21#define SYS_kill 62
22#define SYS_fcntl 72
23#define SYS_getrlimit 97
24#define SYS_sigaltstack 131
25#define SYS_arch_prctl 158
26#define SYS_gettid 186
27#define SYS_tkill 200
28#define SYS_futex 202
29#define SYS_sched_getaffinity 204
30#define SYS_epoll_create 213
31#define SYS_exit_group 231
32#define SYS_epoll_wait 232
33#define SYS_epoll_ctl 233
34#define SYS_pselect6 270
35#define SYS_epoll_create1 291
这些 syscall 理论上都是不会在执行期间被调度器剥离掉 p 的,所以执行成功之后 goroutine 会继续执行,而不像用户的 goroutine 一样,若被剥离 p 会进入等待队列。
既然要和调度交互,那友好地通知我要 syscall 了: entersyscall,我完事了: exitsyscall。
所以这里的交互指的是用户代码使用 syscall 库时和调度器的交互。runtime 里的 syscall 不走这套流程。
1// syscall 库和 cgo 调用的标准入口
2//go:nosplit
3func entersyscall() {
4 reentersyscall(getcallerpc(), getcallersp())
5}
6
7//go:nosplit
8func reentersyscall(pc, sp uintptr) {
9 g := getg()
10
11 // 需要禁止 g 的抢占
12 g.m.locks++
13
14 // entersyscall 中不能调用任何会导致栈增长/分裂的函数
15 g.stackguard0 = stackPreempt
16 // 设置 throwsplit,在 newstack 中,如果发现 throwsplit 是 true
17 // 会直接 crash
18 // 下面的代码是 newstack 里的
19 // if thisg.m.curg.throwsplit {
20 // throw(“runtime: stack split at bad time”)
21 // }
22 g.throwsplit = true
23
24 // Leave SP around for GC and traceback.
25 // 保存现场,在 syscall 之后会依据这些数据恢复现场
26 save(pc, sp)
27 g.syscallsp = sp
28 g.syscallpc = pc
29 casgstatus(g, Grunning, _Gsyscall)
30 if _g.syscallsp < g.stack.lo || g.stack.hi < g.syscallsp {
31 systemstack(func() {
32 print(“entersyscall inconsistent “, hex(g.syscallsp), “ [“, hex(g.stack.lo), “,”, hex(g.stack.hi), “]\n”)
33 throw(“entersyscall”)
34 })
35 }
36
37 if atomic.Load(&sched.sysmonwait) != 0 {
38 systemstack(entersyscallsysmon)
39 save(pc, sp)
40 }
41
42 if _g.m.p.ptr().runSafePointFn != 0 {
43 // runSafePointFn may stack split if run on this stack
44 systemstack(runSafePointFn)
45 save(pc, sp)
46 }
47
48 g.m.syscalltick = g.m.p.ptr().syscalltick
49 g.sysblocktraced = true
50 g.m.mcache = nil
51 g.m.p.ptr().m = 0
52 atomic.Store(&g.m.p.ptr().status, Psyscall)
53 if sched.gcwaiting != 0 {
54 systemstack(entersyscall_gcwait)
55 save(pc, sp)
56 }
57
58 _g.m.locks–
59}
可以看到,进入 syscall 的 G 是铁定不会被抢占的。
1// g 已经退出了 syscall
2// 需要准备让 g 在 cpu 上重新运行
3// 这个函数只会在 syscall 库中被调用,在 runtime 里用的 low-level syscall
4// 不会用到
5// 不能有 write barrier,因为 P 可能已经被偷走了
6//go:nosplit
7//go:nowritebarrierrec
8func exitsyscall(dummy int32) {
9 g := getg()
10
11 g.m.locks++ // see comment in entersyscall
12 if getcallersp(unsafe.Pointer(&dummy)) > g.syscallsp {
13 // throw calls print which may try to grow the stack,
14 // but throwsplit == true so the stack can not be grown;
15 // use systemstack to avoid that possible problem.
16 systemstack(func() {
17 throw(“exitsyscall: syscall frame is no longer valid”)
18 })
19 }
20
21 g.waitsince = 0
22 oldp := g.m.p.ptr()
23 if exitsyscallfast() {
24 if g.m.mcache == nil {
25 systemstack(func() {
26 throw(“lost mcache”)
27 })
28 }
29 // 目前有 p,可以运行
30 g.m.p.ptr().syscalltick++
31 // 把 g 的状态修改回 running
32 casgstatus(g, Gsyscall, _Grunning)
33
34 // 垃圾收集未在运行(因为我们这段逻辑在执行)
35 // 所以清理掉 syscallsp 是安全的
36 _g.syscallsp = 0
37 g.m.locks–
38 if g.preempt {
39 // 防止在 newstack 中清理掉 preemption 标记
40 g.stackguard0 = stackPreempt
41 } else {
42 // 否则恢复在 entersyscall/entersyscallblock 中破坏掉的正常的 StackGuard
43 _g.stackguard0 = g.stack.lo + StackGuard
44 }
45 _g.throwsplit = false
46 return
47 }
48
49 g.sysexitticks = 0
50 g.m.locks–
51
52 // 调用 scheduler
53 mcall(exitsyscall0)
54
55 if g.m.mcache == nil {
56 systemstack(func() {
57 throw(“lost mcache”)
58 })
59 }
60
61 // 调度器返回了,所以我们可以清理掉在 syscall 期间为垃圾收集器
62 // 准备的 syscallsp 信息了
63 // 需要一直等待到 gosched 返回,我们不确定垃圾收集器是不是在运行
64 g.syscallsp = 0
65 g.m.p.ptr().syscalltick++
66 g.throwsplit = false
67}
这里还调用了 exitsyscallfast 和 exitsyscall0。
1//go:nosplit
2func exitsyscallfast() bool {
3 g := getg()
4
5 // Freezetheworld sets stopwait but does not retake P’s.
6 if sched.stopwait == freezeStopWait {
7 g.m.mcache = nil
8 g.m.p = 0
9 return false
10 }
11
12 // Try to re-acquire the last P.
13 if g.m.p != 0 && g.m.p.ptr().status == Psyscall && atomic.Cas(&_g.m.p.ptr().status, Psyscall, _Prunning) {
14 // There’s a cpu for us, so we can run.
15 exitsyscallfast_reacquired()
16 return true
17 }
18
19 // Try to get any other idle P.
20 oldp := _g.m.p.ptr()
21 g.m.mcache = nil
22 g.m.p = 0
23 if sched.pidle != 0 {
24 var ok bool
25 systemstack(func() {
26 ok = exitsyscallfast_pidle()
27 })
28 if ok {
29 return true
30 }
31 }
32 return false
33}
总之就是努力获取一个 P 来执行 syscall 之后的逻辑。如果哪都没有 P 可以给我们用,那就进入 exitsyscall0 了。
1mcall(exitsyscall0)
调用 exitsyscall0 时,会切换到 g0 栈。
1// 在 exitsyscallfast 中吃瘪了,没办法,慢慢来
2// 把 g 的状态设置成 runnable,先进 runq 等着
3//go:nowritebarrierrec
4func exitsyscall0(gp *g) {
5 g := getg()
6
7 casgstatus(gp, Gsyscall, _Grunnable)
8 dropg()
9 lock(&sched.lock)
10 _p := pidleget()
11 if p == nil {
12 // 如果 P 被人偷跑了
13 globrunqput(gp)
14 } else if atomic.Load(&sched.sysmonwait) != 0 {
15 atomic.Store(&sched.sysmonwait, 0)
16 notewakeup(&sched.sysmonnote)
17 }
18 unlock(&sched.lock)
19 if p != nil {
20 // 如果现在还有 p,那就用这个 p 执行
21 acquirep(p)
22 execute(gp, false) // Never returns.
23 }
24 if g.m.lockedg != 0 {
25 // 设置了 LockOsThread 的 g 的特殊逻辑
26 stoplockedm()
27 execute(gp, false) // Never returns.
28 }
29 stopm()
30 schedule() // Never returns.
31}
知道自己会 block,直接就把 p 交出来了。
1// 和 entersyscall 一样,就是会直接把 P 给交出去,因为知道自己是会阻塞的
2//go:nosplit
3func entersyscallblock(dummy int32) {
4 g := getg()
5
6 g.m.locks++ // see comment in entersyscall
7 g.throwsplit = true
8 g.stackguard0 = stackPreempt // see comment in entersyscall
9 g.m.syscalltick = g.m.p.ptr().syscalltick
10 g.sysblocktraced = true
11 g.m.p.ptr().syscalltick++
12
13 // Leave SP around for GC and traceback.
14 pc := getcallerpc()
15 sp := getcallersp(unsafe.Pointer(&dummy))
16 save(pc, sp)
17 g.syscallsp = g.sched.sp
18 g.syscallpc = g.sched.pc
19 if g.syscallsp < g.stack.lo || g.stack.hi < g.syscallsp {
20 sp1 := sp
21 sp2 := g.sched.sp
22 sp3 := g.syscallsp
23 systemstack(func() {
24 print(“entersyscallblock inconsistent “, hex(sp1), “ “, hex(sp2), “ “, hex(sp3), “ [“, hex(g.stack.lo), “,”, hex(g.stack.hi), “]\n”)
25 throw(“entersyscallblock”)
26 })
27 }
28 casgstatus(g, Grunning, _Gsyscall)
29 if _g.syscallsp < g.stack.lo || g.stack.hi < g.syscallsp {
30 systemstack(func() {
31 print(“entersyscallblock inconsistent “, hex(sp), “ “, hex(g.sched.sp), “ “, hex(g.syscallsp), “ [“, hex(g.stack.lo), “,”, hex(g.stack.hi), “]\n”)
32 throw(“entersyscallblock”)
33 })
34 }
35
36 // 直接调用 entersyscallblockhandoff 把 p 交出来了
37 systemstack(entersyscallblock_handoff)
38
39 // Resave for traceback during blocked call.
40 save(getcallerpc(), getcallersp(unsafe.Pointer(&dummy)))
41
42 _g.m.locks–
43}
这个函数只有一个调用方 notesleepg,这里就不再赘述了。
1func entersyscallblock_handoff() {
2 handoffp(releasep())
3}
比较简单。
1func entersyscall_sysmon() {
2 lock(&sched.lock)
3 if atomic.Load(&sched.sysmonwait) != 0 {
4 atomic.Store(&sched.sysmonwait, 0)
5 notewakeup(&sched.sysmonnote)
6 }
7 unlock(&sched.lock)
8}
1func entersyscallgcwait() {
2 _g := getg()
3 p := g.m.p.ptr()
4
5 lock(&sched.lock)
6 if sched.stopwait > 0 && atomic.Cas(&p.status, Psyscall, _Pgcstop) {
7 _p.syscalltick++
8 if sched.stopwait–; sched.stopwait == 0 {
9 notewakeup(&sched.stopnote)
10 }
11 }
12 unlock(&sched.lock)
13}
提供给用户使用的系统调用,基本都会通知 runtime,以 entersyscall,exitsyscall 的形式来告诉 runtime,在这个 syscall 阻塞的时候,由 runtime 判断是否把 P 腾出来给其它的 M 用。解绑定指的是把 M 和 P 之间解绑,如果绑定被解除,在 syscall 返回时,这个 g 会被放入执行队列 runq 中。
同时 runtime 又保留了自己的特权,在执行自己的逻辑的时候,我的 P 不会被调走,这样保证了在 Go 自己“底层”使用的这些 syscall 返回之后都能被立刻处理。
所以同样是 epollwait,runtime 用的是不能被别人打断的,你用的 syscall.EpollWait 那显然是没有这种特权的。
参考资料如下
https://z.didi.cn/1HecgP
对于 Go 这种本身便是为并发而生的语言来说,使用 io_uring
这种系统级异步接口也不是那么的迫切
比如对于普通文件的读写以及 socket 的操作都会通过 netpoll
来进行优化,当文件/套接字可读可写时 netpoll 便会唤醒相应 goroutine 来对文件进行读写
而对于可能会阻塞的系统调用,在 syscall 底层调用 syscall.Syscall/Syscall6
时配合 runtime 判断是否将 P 和 G 绑定的 M 解绑,然后将 P 交给其他 M 来使用,通过这种机制可以减少系统调用从用户态切换到内核态对整个程序带来的损耗
Go runtime 实际上已经实现了用户态的并发 IO,现在 Linux 内核提供了新的异步 IO 接口,那又该如何去利用这种新的技术呢
我们首先先看一下当前 Go 是如何做到异步 IO 的
1 | // src/os/file.go |
从 os.Open
到 newFile
,可以看到文件的 文件描述符
被放到 poll.FD
进行初始化了, poll.FD.Init
便是将文件描述符
注册到 netpoll(epoll)
中
需要注意当文件被注册到
netpoll(epoll)
后,会将它置为非阻塞模式(SetNonblock
),因为netpoll(epoll)
采用的是边缘触发模式
比如说非阻塞文件描述符中有可读事件时,epoll
只会通知一次(除非有新的数据被写入文件会再次通知),也就说需要所有数据读出来直到返回-EAGAIN
,对于阻塞模式的 socket 文件,当从 socket 中读取数据时就可能会阻塞等待,这样也就失去了 epoll 的意义
我们可以再看一下 poll.FD
是如何利用 netpoll
进行读取的
1 | // src/internal/poll/fd_unix.go |
可以看到 ignoringEINTR
中调用 syscall.Read
读取文件,如果出现 syscall.EAGAIN
,那么就调用 fd.pd.waitRead
来等待数据可读
1 | // src/internal/poll/fd_unix.go |
pollDesc
Go 对 netpoll
的抽象
1 | // src/internal/poll/fd_poll_runtime.go |
runtime_poll*
这些函数才是真正的 netpoll,而这些函数是在src/runtime/netpoll.go 中实现,并通过 go:linkname
来链接到 internal/poll 中
1 | // src/runtime/netpoll.go |
根据具体的平台来实现 poller,对于 Linux,便是使用 epoll
1 | // src/runtime/netpoll_epoll.go |
添加新的文件描述符时,可以发现fd
是以 边缘触发
的方式注册到 netpoll(epoll)
中
从 netpoll
这个名字上就可以看出,netpoll
是 Go 为了高性能的异步网络而实现的
看一下创建 TCPListener socket 的流程
1 | // src/net/tcpsock.go |
创建 TCPListener 链路还是挺长的,不过在第四步 socket
函数中可以看到调用 newFD
来返回 netFD
实例,而 netFD.pfd
便是 poll.FD
, 而对 netFD
的读写和文件 IO 一样便都会通过 poll.FD
来利用 netpoll
。
通过 poll.pollDesc
将文件描述符加入到 netpoll
后,当对文件描述符进行读写时,如果 syscall.Read
返回 syscall.EAGAIN
的话就需要调用 pollDesc.waitRead/waitWrite
来等待可读可写
1 | // src/internal/poll/fd_poll_runtime.go |
等待文件可读写,最终会调用 netpollblock
函数,并不会直接调用 epoll wait 的系统调用,而是挂起当前 goroutine, 并等待唤醒
1 | // src/runtime/netpoll_epoll.go |
netpoll
会调用 epollWait
来获取 epoll 事件,而在 runtime 中很多地方都会调用 netpoll
函数
监控函数 sysmon
1 | // src/runtime/proc.go |
查找可运行的 goroutine
1 | // src/runtime/proc.go |
GC 时调用 startTheWorld
1 | // src/runtime/proc.go |
通常获取可用的 goroutine 时都可能有机会去调用 netpoll
,然后再调用 injectglist(&list)
将可运行 goroutine 加入到runq
队列中
本节不会详细介绍 io_uring 的具体操作,关于 io_uring 的使用,可以查看 Lord of the io_uring
Linux kernel 5.1 新增了异步接口 io_uring
,它是 Jens Axboe 以高效,可扩展,易用为目的设计的一种全新异步接口,为什么是全新呢,因为 Linux 已经提供了异步 IO 接口 —— AIO,不过就连 Linus 都对它一阵吐槽
Re: [PATCH 09/13] aio: add support for async openat()
So I think this is ridiculously ugly.
AIO is a horrible ad-hoc design, with the main excuse being “other,
less gifted people, made that design, and we are implementing it for
compatibility because database people - who seldom have any shred of
taste - actually use it”.
But AIO was always really really ugly.
io_uring 提供的异步接口,不仅仅可以使用 文件 IO,套接字 IO,甚至未来可以扩展加入其它系统调用
而且 io_uring
采用应用程序和内核共享内存的方式,来提交请求和获取完成事件
使用共享内存的方式可能是内核对接口优化的一种趋势
iouring 名称的意思便是 _io use ring,而 ring 便是指和内核内存共享的 提交队列
和完成队列
两个环形缓冲区
我们来看一下 io_uring 用来提交请求的结构
1 | struct io_uring_sqe { |
io_uring_sqe
一个非常复杂的结构,最核心的三个字段 opcode
,fd
,user_data
opcode
指定具体是什么操作,比如 IORING_OP_READV
,IORING_OP_ACCEPT
,IORING_OP_OPENAT
,现在支持 35 种操作fd
表示用于 IO 操作的文件描述符user_data
当请求操作完成后,io_uring 会生成一个完成事件放到 完成队列(CompletionQueue)
中,而 user_data
便是用来和完成队列
中的事件进行绑定的,他会原封不动的复制到完成队列的事件(cqe)
中flags
字段用来实现链式请求之类的功能可以发现
opcode
是 uint8,也就是现在来看最多支持 256 个系统调用,但是整个io_uring_sqe
还为未来预留了一些空间__pad2
通过 opcode
结合结合其他 union
的字段,便实现了扩展性极强的 io_uring
接口
完成队列事件(CompletionQueueEvent)
的结构就比较简单了,主要是表示提交的异步操作执行的结果
1 | struct io_uring_cqe { |
user_data
便是从 sqe->data
直接复制过来了,可以通过 user_data
绑定到对应的 sqe
res
便是异步操作执行的结果,如果 res < 0,通常说明操作执行错误flags
暂时没有使用
io_uring 使用共享内存的方式,来提交请求和获取执行结果,减少内存拷贝带来的损耗io_uring_setup
接口会接受一个指定提交队列大小的 uint32
类型参数和一个 io_uring_params
对象
1 | #include <linux/io_uring.h> |
调用成功会返回一个文件描述符
,用于后续的操作
1 | struct io_uring_params { |
io_uring_params
不只用来配置 io_uring
实例,内核也会填充 io_uring_params
中关于 io_uring
实例的信息,比如用来映射共享内存的请求队列和完成队列字段的偏移量 - io_sqring_offsets
和 io_cqring_offsets
flags
以位掩码的方式,结合相应 sq_thread_cpu
,sq_thread_idle
,wq_fd
,cq_entries
字段来配置 io_uring 实例
1 | /* |
通常 cq_entries 为 sq_entries 的两倍,通过 flags
指定 IORING_SETUP_CQSIZE
,然后设置 cq_entries
字段为指定大小
cq_entries 不能小于 sq_entries
iouring-go 提供了初始化 io_uring 对象时的配置函数,可以看一下这些函数的具体实现
1 | type IOURingOption func(*IOURing) |
内核会向 io_uring_params
填充跟 io_uring 实例相关的信息sq_entries
请求队列的大小,io_uring_setup
会传递请求队列的大小 entries
,io_uring 会根据 entries
设置 sq_entries
为 2 的次方大小cq_entries
完成队列的大小,通常为 sq_entries
的两倍,即使通过 IORING_SETUP_CQSIZE
flag 设置了 cq_enries
,内核依然会以 2 的次方重新计算出 cq_entries
的大小features
记录了当前内核版本支持的一些功能
1 | /* |
io_sqring_offsets
和 io_cqring_offsets
便是SQ
和CQ
在共享内存中的偏移量
1 | struct io_sqring_offsets { |
根据这些偏移量便可以调用 mmap 来映射 SQ 和 CQ
1 | ptr = mmap(0, sq_off.array + sq_entries * sizeof(__u32), |
可以参考 iouring-go 对 IOURing 对象的初始化
1 | // iouring-go/iouring.go |
mmapIOURing
中实现了对请求队列以及完成队列的内存映射
1 | // iouring-go/mmap.go |
这里不再详细介绍 io_uring
的使用 ,想要了解更多可以查看文末的推荐阅读
这里简单介绍一下 io_uring
提供的一些功能,以及在 Go 中如何去使用
设置 sqe->flags
的 IOSQE_IO_DRAIN
标记,这样只有当该 sqe
之前所有的 sqes
都完成后,才会执行该 sqe
,而后续的 sqe
也会在该 sqe
完成后才会执行
在 iouring-go 中可以在构建 IOURing
对象时使用 WithDrain
来全局设置请求顺序执行
1 | iour := iouring.New(8, WithDrain()) |
针对单一请求设置 WithDrain
,保证请求会在之前所有的请求都完成才会执行,而后续的请求也都会在该请求完成之后才会开始执行
1 | request, err := iour.SubmitRequest(iouring.Read(fd, buf).WithDrain(), nil) |
io_uring
提供了一组请求的链式/顺序执行的方法,可以让链中的请求会在上一个请求执行完成后才会执行,而且不影响链外其他请求的并发执行
设置 sqe->flags
的 IOSQE_IO_LINK
标记后,下一个 sqe
和当前 sqe
自动组成新链或者当前 sqe
的链中,链中没有设置 IOSQWE_IO_LINK
的 sqe
便是链尾
如果链中的有请求执行失败了,那么链中后续的 sqe
都会被取消( cqe.res
为 -ECANCELED
)io_uring
还提供了以外一种设置链式请求的方式,设置 sqe->flags
为 IOSQE_IO_HARDLINK
flag,这种方式会让链中的请求忽略之前请求的结果,也就是说即使链中之前的请求执行失败了,也不会取消链中后边的请求
iouring-go 中可以使用 SubmitLinkRequests
或者 SubmitHardLinkRequests
方法来设置链式请求
1 | preps := []iouring.PrepRequest{ iouring.Read(fd1, buf), iouring.Write(fd2, buf) } |
当请求提交后,还可以提交取消请求的请求,这样如果请求还没有执行或者请求的操作可以被中断(比如 socket IO),那么就可以被异步的取消,而对于已经启动的磁盘 IO 请求则无法取消
在 iouring-go 中,提交请求后会返回一个 iouring.Request
对象,通过request.Cancel
方法就可以取消请求
1 | request, err := iour.SubmitRequest(iouring.Timeout(1 * time.Second), nil) |
Cancel
方法会返回一个 cancelRequest 对象,表示提交的取消请求
可以监听 request
的执行是否失败,并且失败原因是否为 iouring.ErrRequestCanceled
1 | <- request.Done() |
也可去监听 cancelRequest 的执行结果,如果cancelRequest.Err
方法返回 nil
,便是可能成功取消了,注意是可能取消了,因为一些操作是无法被取消的
1 | <- cancelRequest.Done() |
io_uring
提供了 IORING_OP_TIMEOUT
请求,可以用来提交超时请求
超时请求可以分为三种:
iouring-go 对这三种情况封装了三个函数 iouring.Timeout
,iouring.TimeoutWithTime
,iouring.CountCompletionEvent
来分别代表三种超时请求
1 | now := time.Now() |
根据 io_uring
提供的超时请求,可以实现系统级的异步定时器
io_uring
通过 IOSQE_IO_LINK
将一个请求和 IORING_OP_LINK_TIMEOUT
请求链接在一起,那么就可以做到请求的超时控制
iouring-go 同样提供了简便方法 WithTimeout
1 | preps := iouring.Read(fd, buf).WithTimeout() |
WithTimeout
方法会返回两个 PrepRequest
对象,所以需要使用 SubmitRequests
来提交
iouring-go 中请求超时的一些操作使用起来感觉还不是特别友好,有待优化
io_uring
的一些 IO 操作需要提供文件描述符,而频繁的将文件和内核之间进行映射也会导致一定的性能损耗,所以可以使用 io_uring
的 io_uring_register
接口来提前注册文件描述符
详细的概念可以参考 io_uring_register
iouring-go 也提供了文件描述符的注册功能,而且对于已经注册的文件描述符会自动使用
1 | func (iour *IOURing) RegisterFile(file *os.File) error |
当 io_uring
的文件描述符被关闭后,这些注册的文件会自动注销
需要注意,调用 io_uring_register
来注册文件描述符时,如果有其他的正在进行的请求的话,会等到这些请求都完成才会注册
注册文件描述符在 Go 中带来的并发问题
1 | type fileRegister struct { |
需要注意由于存在对索引 fileRegister.indexs
的并发读写,所以使用 sync.Map
,也就意味着,使用注册文件描述符,会带来一定的并发问题,经过简单的测试,sync.Map
带来的性能损耗导致注册文件描述符带来的优势并没有那么大的
在 Go 中使用
io_uring
的最大问题便是对io_uring
实例的竞争问题,而通过 Go 暴露给外部使用的并发机制,并不能让io_uring
带来的异步 IO 发挥最大的性能
将io_uring
融入 runtime 中,才是最终的解决方案
和注册文件描述符类似, io_uring
为了减少 IO 请求中缓冲区的映射,同样可以使用 io_uring_register
来注册缓冲区
如果要在请求中使用缓冲区的话,需要使用 IORING_OP_READ_FIXED
或者 IORING_OP_WRITE_FIXED
请求
具体可以参考 io_uring_register
将请求放到SQ
的环形缓冲区后,需要调用 io_uring_enter
来通知内核有请求需要处理
io_uring 为了进一步减少系统调用,可以在 io_uring_setup
是设置 io_uring_params->flags
的 IORING_SETUP_SQPOLL
flags,内核就会创建一个轮询请求队列的线程
可以通过 ps
命令查看用来轮询的内核线程
1 | ps --ppid 2 | grep io_uring-sq |
需要注意在 5.10 之前的版本,需要使用特权用户来执行,而 5.10 以后只需 CAP_SYS_NICE
权限即可
并且 5.10 之前,SQPoll 需要配合注册的文件描述符一起使用,而 5.10 以后则不需要,可以通过查看内核填充的 io_uring_params->features
是否设置了 IORING_FEAT_SQPOLL_NONFIXED
1 | // iouring-go/iouring.go |
iouring-go 同样提供了开启 SQPoll 的 WithSQPoll
以及设置与 SQPoll 内核线程的相关配置 WithSQPollThreadCpu
和 WithSQPollThreadIdle
1 | iour, err := iouring.New(8, iouring.WithSQPoll()) |
但是在 Go 简单的设置 io_uring_params
并不能正常的工作,可能是由于 Go 的 GMP 模型导致的一些问题。暂时还在思考解决方案
通过 io_uring_register
可以将 eventfd
注册到 io_uring 实例中,然后将 eventfd
加入到 epoll
中,如果当 io_uring 中有完成事件时,便会通知 eventfd
在 iouring-go 中,对于完成事件的监听便是使用了 eventfd
和 epoll
1 | type IOURing struct { |
poller
会调用 EpollWait 等待完成队列
中有完成事件,并通知相应的 IOURing 对象
1 | // iouring-go/iouring.go |
默认情况下, CQ 环的大小是 SQ 环的 两倍,为什么 SQ 环的大小会小于 CQ 环,是因为 SQ 环中的 sqe 一旦被内核发现,便会被内核消耗掉,也就意味着 sqe 的生命周期很短,而请求的完成事件都会放到 CQ 环中
我们也可以通过 IORING_SETUP_CQSIZE
或者 iouring-go
的 WithCQSize
Option 里设置 CQ 环的大小
但是依然会存在 CQ 环溢出的情况,而内核会在内部存储溢出的时间,直到 CQ 环有空间容纳更多事件。
可以通过 io_uring_params->features 是否设置 IORING_FEAT_NODROP 来判断当前内核是否支持该功能
如果 CQ 环溢出,那么提交请求时可能会以 -EBUSY
错误失败,需要重新提交
并且当 CQ 环中数据被消耗后,需要调用 io_uring_enter
来通知内核 CQ 环中有空余空间
1 | func (iour *IOURing) getCQEvent(wait bool) (cqe *iouring_syscall.CompletionQueueEvent, err error) { |
在实现 iouring-go 中遇到的问题,一个是并发导致对 io_uring
的竞争问题
对于 CQ 环的竞争是使用单一的 CQ 环消费 goroutine IOURing.run()
来完成 cqe
的消费
1 | func New(entries int, opts ...IOURingOption) (iour *IOURing, err error) { |
SQ 环的解决方案有两种
第一种方式听起来使用 channel 更优雅一些,但是 channel 内部依然使用锁的方式以及额外的内存复制
另外最大的弊端就是将 IOURIng
的提交函数
(_将请求发送给提交 channel_)和真正将请求提交给内核(_调用 io_uring_enter
通知内核有新的请求_)分开
当多个提交函数
向 channel 发送的请求的顺序无法保证,这样链式请求就无法实现(除非对于链式请求再次加锁)
第二种方式,采用加锁的方式,保证了同一时间只有一个提交函数在处理 SQ 环,并且可以立即是否真正提交成功(_调用 IOURing.submit
方法通知内核有新的请求_)
iouring-go 采用了第二种方式
真正去解决这个问题的方式,估计可能只有 runtime 才能给出答案,为每一个 P 创建一个 io_uring 实例在 runtime 内部解决竞争问题,内部使用 eventfd 注册到 netpoll
中来获取完成队列
通知
对于 iouring-go 设计比较好的地方,我感觉便是对 channel 的利用,异步 IO 加上 channel,可以将异步在并发的程序中发挥出最大的作用
当然,如果只是简单的使用 channel 的话又会引入其他一些问题,后续会进行说明
1 | func (iour *IOURing) SubmitRequest(request PrepRequest, ch chan<- Result) (Request, error) |
SubmitRequest
方法接收一个 channel,当请求完成后,会将结果发送到 channel 中,这样通过多个请求复用同一个 channel,程序便可以监听一组请求的完成情况
1 | func (iour *IOURing) run() { |
而 SubmitRequest
方法同样会返回一个 Request 接口对象,通过 Request 我们同样可以去查看请求的是否完成已经它的完成结果
1 | type Request interface { |
利用 channel 便可以完成对异步 IO 的异步监听和同步监听
当然使用 channel 又会带来其他的问题,比如 channel 满了以后,对 io_uring 完成队列的消费便会阻塞在向 channel 发送数据,阻塞时间过长也会导致 CQ 环溢出
比较好的解决方案是,在 channel 上抽象出一层 Resulter
,Resulter
会对完成事件进行自动缓冲,当然这也会带来一定的代码复杂度,所以 iouring-go 便将 channel 阻塞的问题交给使用者,要求 channel 的消费端尽快消费掉数据
netpoll
在 Linux 平台下使用了 epoll
,而且 epoll
在使用上并没有竞争问题,当然如果要使用 io_uring
来替代 epoll
来实现 netpoll
的话并不是不可能,只是这样对于工作很好的 epoll 来说并没有什么必要,而且是否能够带来可观的性能收益也都是不确定的
在高并发的情况下,有限的 SQ 环和 CQ 环,对于请求数量大于完成事件的消费速度的情况,CQ 环的大量溢出带来对内核的压力以及新的请求提交带来的错误处理,都会提高真正利用 io_uring
的难度
对于 SQ 环和 CQ 环的大小限制,也许需要通过 Pool 的方式来解决,初始化多个 io_uring 实例,当一个实例的 SQ 环满,那么就使用另外的实例来提交请求
而使用 Pool 又会增加一定的复杂度io_uring
的功能实际可以覆盖了 epoll
的,比如提交的阻塞 IO 请求便相当于 epoll
+ syscall
,另外 io_uring
还提供了超时设置和请求的超时控制,相当于实现了系统级的定时器以及 netpoll
的 deadline
但是 epoll 自身的优势,比如没有竞争问题,没有监听文件描述符的数量限制,都让 epoll 在实际的使用中更加好用,而这些问题对于 io_uring
在本身设计上就会导致的问题
比如竞争问题,使用环形缓冲区可以协调应用和内核对请求队列的访问,但是应用中多个线程或者 goroutine 就会引发对环形缓冲区的竞争问题
而请求数量的限制,那么就需要考虑到请求完成事件的溢出问题,内核不能无限制的去保存溢出的完成事件,当然这个问题通过应用中在 io_uring
实例上抽象出 io_uring 池
的方式来解决
使用 io_uring
来实现异步网络框架,对已有的网络模型会是非常大的冲击,怎么去使用 io_uring
来发挥最大的能力依然处于探索阶段,毕竟 io_uring
是一个出现才 1 年的技术
而对于普通的磁盘 IO 来说,io_uring
还是有很大的发挥空间的,利用 Go 中已有的并发机制,结合具体的性能评估,对于文件服务器来说,也许会带来极大的提升
另外一个问题便是,对于 5.1 引入,5.6 开始功能变得丰富成熟的 io_uring
来说,现在大量的环境处于 3.X,4.X,甚至 2.X , io_uring
仍然需要等待时机才能去发挥它真正的作用,而这段时间便是留给我们去探讨怎么让 io_uring
更好用
并发(并行),一直以来都是一个编程语言里的核心主题之一,也是被开发者关注最多的话题;Go 语言作为一个出道以来就自带 『高并发』光环的富二代编程语言,它的并发(并行)编程肯定是值得开发者去探究的,而 Go 语言中的并发(并行)编程是经由 goroutine 实现的,goroutine 是 golang 最重要的特性之一,具有使用成本低、消耗资源低、能效高等特点,官方宣称原生 goroutine 并发成千上万不成问题,于是它也成为 Gopher 们经常使用的特性。
Goroutine 是优秀的,但不是完美的,在极大规模的高并发场景下,也可能会暴露出问题,什么问题呢?又有什么可选的解决方案?本文将通过 runtime 对 goroutine 的调度分析,帮助大家理解它的机理和发现一些内存和调度的原理和问题,并且基于此提出一种个人的解决方案 — 一个高性能的 Goroutine Pool(协程池)。
Goroutine,Go 语言基于并发(并行)编程给出的自家的解决方案。goroutine 是什么?通常 goroutine 会被当做 coroutine(协程)的 golang 实现,从比较粗浅的层面来看,这种认知也算是合理,但实际上,goroutine 并非传统意义上的协程,现在主流的线程模型分三种:内核级线程模型、用户级线程模型和两级线程模型(也称混合型线程模型),传统的协程库属于用户级线程模型,而 goroutine 和它的 Go Scheduler
在底层实现上其实是属于两级线程模型,因此,有时候为了方便理解可以简单把 goroutine 类比成协程,但心里一定要有个清晰的认知 — goroutine 并不等同于协程。
互联网时代以降,由于在线用户数量的爆炸,单台服务器处理的连接也水涨船高,迫使编程模式由从前的串行模式升级到并发模型,而几十年来,并发模型也是一代代地升级,有 IO 多路复用、多进程以及多线程,这几种模型都各有长短,现代复杂的高并发架构大多是几种模型协同使用,不同场景应用不同模型,扬长避短,发挥服务器的最大性能,而多线程,因为其轻量和易用,成为并发编程中使用频率最高的并发模型,而后衍生的协程等其他子产品,也都基于它,而我们今天要分析的 goroutine 也是基于线程,因此,我们先来聊聊线程的三大模型:
线程的实现模型主要有 3 种:内核级线程模型、用户级线程模型和两级线程模型(也称混合型线程模型),它们之间最大的差异就在于用户线程与内核调度实体(KSE,Kernel Scheduling Entity)之间的对应关系上。而所谓的内核调度实体 KSE 就是指可以被操作系统内核调度器调度的对象实体(这说的啥玩意儿,敢不敢通俗易懂一点?)。简单来说 KSE 就是内核级线程,是操作系统内核的最小调度单元,也就是我们写代码的时候通俗理解上的线程了(这么说不就懂了嘛!装什么 13)。
用户线程与内核线程 KSE 是多对一(N : 1)的映射模型,多个用户线程的一般从属于单个进程并且多线程的调度是由用户自己的线程库来完成,线程的创建、销毁以及多线程之间的协调等操作都是由用户自己的线程库来负责而无须借助系统调用来实现。一个进程中所有创建的线程都只和同一个 KSE 在运行时动态绑定,也就是说,操作系统只知道用户进程而对其中的线程是无感知的,内核的所有调度都是基于用户进程。许多语言实现的 协程库 基本上都属于这种方式(比如 python 的 gevent)。由于线程调度是在用户层面完成的,也就是相较于内核调度不需要让 CPU 在用户态和内核态之间切换,这种实现方式相比内核级线程可以做的很轻量级,对系统资源的消耗会小很多,因此可以创建的线程数量与上下文切换所花费的代价也会小得多。但该模型有个原罪:并不能做到真正意义上的并发,假设在某个用户进程上的某个用户线程因为一个阻塞调用(比如 I/O 阻塞)而被 CPU 给中断(抢占式调度)了,那么该进程内的所有线程都被阻塞(因为单个用户进程内的线程自调度是没有 CPU 时钟中断的,从而没有轮转调度),整个进程被挂起。即便是多 CPU 的机器,也无济于事,因为在用户级线程模型下,一个 CPU 关联运行的是整个用户进程,进程内的子线程绑定到 CPU 执行是由用户进程调度的,内部线程对 CPU 是不可见的,此时可以理解为 CPU 的调度单位是用户进程。所以很多的协程库会把自己一些阻塞的操作重新封装为完全的非阻塞形式,然后在以前要阻塞的点上,主动让出自己,并通过某种方式通知或唤醒其他待执行的用户线程在该 KSE 上运行,从而避免了内核调度器由于 KSE 阻塞而做上下文切换,这样整个进程也不会被阻塞了。
用户线程与内核线程 KSE 是一对一(1 : 1)的映射模型,也就是每一个用户线程绑定一个实际的内核线程,而线程的调度则完全交付给操作系统内核去做,应用程序对线程的创建、终止以及同步都基于内核提供的系统调用来完成,大部分编程语言的线程库(比如 Java 的 java.lang.Thread、C++11 的 std::thread 等等)都是对操作系统的线程(内核级线程)的一层封装,创建出来的每个线程与一个独立的 KSE 静态绑定,因此其调度完全由操作系统内核调度器去做,也就是说,一个进程里创建出来的多个线程每一个都绑定一个 KSE。这种模型的优势和劣势同样明显:优势是实现简单,直接借助操作系统内核的线程以及调度器,所以 CPU 可以快速切换调度线程,于是多个线程可以同时运行,因此相较于用户级线程模型它真正做到了并行处理;但它的劣势是,由于直接借助了操作系统内核来创建、销毁和以及多个线程之间的上下文切换和调度,因此资源成本大幅上涨,且对性能影响很大。
两级线程模型是博采众长之后的产物,充分吸收前两种线程模型的优点且尽量规避它们的缺点。在此模型下,用户线程与内核 KSE 是多对多(N : M)的映射模型:首先,区别于用户级线程模型,两级线程模型中的一个进程可以与多个内核线程 KSE 关联,也就是说一个进程内的多个线程可以分别绑定一个自己的 KSE,这点和内核级线程模型相似;其次,又区别于内核级线程模型,它的进程里的线程并不与 KSE 唯一绑定,而是可以多个用户线程映射到同一个 KSE,当某个 KSE 因为其绑定的线程的阻塞操作被内核调度出 CPU 时,其关联的进程中其余用户线程可以重新与其他 KSE 绑定运行。所以,两级线程模型既不是用户级线程模型那种完全靠自己调度的也不是内核级线程模型完全靠操作系统调度的,而是中间态(自身调度与系统调度协同工作),也就是 — 『薛定谔的模型』(误),因为这种模型的高度复杂性,操作系统内核开发者一般不会使用,所以更多时候是作为第三方库的形式出现,而 Go 语言中的 runtime 调度器就是采用的这种实现方案,实现了 Goroutine 与 KSE 之间的动态关联,不过 Go 语言的实现更加高级和优雅;该模型为何被称为两级?即用户调度器实现用户线程到 KSE 的『调度』,内核调度器实现 KSE 到 CPU 上的『调度』。
每一个 OS 线程都有一个固定大小的内存块(一般会是 2MB)来做栈,这个栈会用来存储当前正在被调用或挂起(指在调用其它函数时)的函数的内部变量。这个固定大小的栈同时很大又很小。因为 2MB 的栈对于一个小小的 goroutine 来说是很大的内存浪费,而对于一些复杂的任务(如深度嵌套的递归)来说又显得太小。因此,Go 语言做了它自己的『线程』。
在 Go 语言中,每一个 goroutine 是一个独立的执行单元,相较于每个 OS 线程固定分配 2M 内存的模式,goroutine 的栈采取了动态扩容方式, 初始时仅为 2KB,随着任务执行按需增长,最大可达 1GB(64 位机器最大是 1G,32 位机器最大是 256M),且完全由 golang 自己的调度器 Go Scheduler 来调度。此外,GC 还会周期性地将不再使用的内存回收,收缩栈空间。 因此,Go 程序可以同时并发成千上万个 goroutine 是得益于它强劲的调度器和高效的内存模型。Go 的创造者大概对 goroutine 的定位就是屠龙刀,因为他们不仅让 goroutine 作为 golang 并发编程的最核心组件(开发者的程序都是基于 goroutine 运行的)而且 golang 中的许多标准库的实现也到处能见到 goroutine 的身影,比如 net/http 这个包,甚至语言本身的组件 runtime 运行时和 GC 垃圾回收器都是运行在 goroutine 上的,作者对 goroutine 的厚望可见一斑。
任何用户线程最终肯定都是要交由 OS 线程来执行的,goroutine(称为 G)也不例外,但是 G 并不直接绑定 OS 线程运行,而是由 Goroutine Scheduler 中的 P - Logical Processor (逻辑处理器)来作为两者的『中介』,P 可以看作是一个抽象的资源或者一个上下文,一个 P 绑定一个 OS 线程,在 golang 的实现里把 OS 线程抽象成一个数据结构:M,G 实际上是由 M 通过 P 来进行调度运行的,但是在 G 的层面来看,P 提供了 G 运行所需的一切资源和环境,因此在 G 看来 P 就是运行它的 “CPU”,由 G、P、M 这三种由 Go 抽象出来的实现,最终形成了 Go 调度器的基本结构:
关于 P,我们需要再絮叨几句,在 Go 1.0 发布的时候,它的调度器其实 G-M 模型,也就是没有 P 的,调度过程全由 G 和 M 完成,这个模型暴露出一些问题:
这些问题实在太扎眼了,导致 Go1.0 虽然号称原生支持并发,却在并发性能上一直饱受诟病,然后,Go 语言委员会中一个核心开发大佬看不下了,亲自下场重新设计和实现了 Go 调度器(在原有的 G-M 模型中引入了 P)并且实现了一个叫做 work-stealing 的调度算法:
该算法避免了在 goroutine 调度时使用全局锁。
至此,Go 调度器的基本模型确立:
Go 调度器工作时会维护两种用来保存 G 的任务队列:一种是一个 Global 任务队列,一种是每个 P 维护的 Local 任务队列。
当通过 go
关键字创建一个新的 goroutine 的时候,它会优先被放入 P 的本地队列。为了运行 goroutine,M 需要持有(绑定)一个 P,接着 M 会启动一个 OS 线程,循环从 P 的本地队列里取出一个 goroutine 并执行。当然还有上文提及的 work-stealing
调度算法:当 M 执行完了当前 P 的 Local 队列里的所有 G 后,P 也不会就这么在那躺尸啥都不干,它会先尝试从 Global 队列寻找 G 来执行,如果 Global 队列为空,它会随机挑选另外一个 P,从它的队列里中拿走一半的 G 到自己的队列中执行。
如果一切正常,调度器会以上述的那种方式顺畅地运行,但这个世界没这么美好,总有意外发生,以下分析 goroutine 在两种例外情况下的行为。
Go runtime 会在下面的 goroutine 被阻塞的情况下运行另外一个 goroutine:
这四种场景又可归类为两种类型:
当 goroutine 因为 channel 操作或者 network I/O 而阻塞时(实际上 golang 已经用 netpoller 实现了 goroutine 网络 I/O 阻塞不会导致 M 被阻塞,仅阻塞 G,这里仅仅是举个栗子),对应的 G 会被放置到某个 wait 队列(如 channel 的 waitq),该 G 的状态由 _Gruning
变为 _Gwaitting
,而 M 会跳过该 G 尝试获取并执行下一个 G,如果此时没有 runnable 的 G 供 M 运行,那么 M 将解绑 P,并进入 sleep 状态;当阻塞的 G 被另一端的 G2 唤醒时(比如 channel 的可读/写通知),G 被标记为 runnable,尝试加入 G2 所在 P 的 runnext,然后再是 P 的 Local 队列和 Global 队列。
当 G 被阻塞在某个系统调用上时,此时 G 会阻塞在 _Gsyscall
状态,M 也处于 block on syscall 状态,此时的 M 可被抢占调度:执行该 G 的 M 会与 P 解绑,而 P 则尝试与其它 idle 的 M 绑定,继续执行其它 G。如果没有其它 idle 的 M,但 P 的 Local 队列中仍然有 G 需要执行,则创建一个新的 M;当系统调用完成后,G 会重新尝试获取一个 idle 的 P 进入它的 Local 队列恢复执行,如果没有 idle 的 P,G 会被标记为 runnable 加入到 Global 队列。
以上就是从宏观的角度对 Goroutine 和它的调度器进行的一些概要性的介绍,当然,Go 的调度中更复杂的抢占式调度、阻塞调度的更多细节,大家可以自行去找相关资料深入理解,本文只讲到 Go 调度器的基本调度过程,为后面自己实现一个 Goroutine Pool 提供理论基础,这里便不再继续深入上述说的那几个调度了,事实上如果要完全讲清楚 Go 调度器,一篇文章的篇幅也实在是捉襟见肘,所以想了解更多细节的同学可以去看看 Go 调度器 G-P-M 模型的设计者 Dmitry Vyukov 写的该模型的设计文档《Go Preemptive Scheduler Design》以及直接去看源码,G-P-M 模型的定义放在 src/runtime/runtime2.go
里面,而调度过程则放在了 src/runtime/proc.go
里。
既然 Go 调度器已经这么牛逼优秀了,我们为什么还要自己去实现一个 golang 的 Goroutine Pool 呢?事实上,优秀不代表完美,任何不考虑具体应用场景的编程模式都是耍流氓!有基于 G-P-M 的 Go 调度器背书,go 程序的并发编程中,可以任性地起大规模的 goroutine 来执行任务,官方也宣称用 golang 写并发程序的时候随便起个成千上万的 goroutine 毫无压力。
然而,你起 1000 个 goroutine 没有问题,10000 也没有问题,10w 个可能也没问题;那,100w 个呢?1000w 个呢?(这里只是举个极端的例子,实际编程起这么大规模的 goroutine 的例子极少)这里就会出问题,什么问题呢?
我想,作为 golang 拥趸的 Gopher 们一定都使用过它的 net/http 标准库,很多人都说用 golang 写 Web server 完全可以不用借助第三方的 Web framework,仅用 net/http 标准库就能写一个高性能的 Web server,的确,我也用过它写过 Web server,简洁高效,性能表现也相当不错,除非有比较特殊的需求否则一般的确不用借助第三方 Web framework,但是天下没有白吃的午餐,net/http 为啥这么快?要搞清这个问题,从源码入手是最好的途径。孔子曾经曰过:源码面前,如同裸奔。所以,高清无码是阻碍程序猿发展大大滴绊脚石啊,源码才是我们进步阶梯,切记切记!
接下来我们就来先看看 net/http 内部是怎么实现的。
net/http 接收请求且开始处理的源码放在 src/net/http/server.go
里,先从入口函数 ListenAndServe
进去:
1 | 1func (srv *Server) ListenAndServe() error { |
看到最后那个 srv.Serve 调用了吗?没错,这个 Serve
方法里面就是实际处理 http 请求的逻辑,我们再进入这个方法内部:
1 | 1func (srv *Server) Serve(l net.Listener) error { |
首先,这个方法的参数(l net.Listener)
,是一个 TCP 监听的封装,负责监听网络端口, rw, e := l.Accept()
则是一个阻塞操作,从网络端口取出一个新的 TCP 连接进行处理,最后 go c.serve(ctx)
就是最后真正去处理这个 http 请求的逻辑了,看到前面的 go 关键字了吗?没错,这里启动了一个新的 goroutine 去执行处理逻辑,而且这是在一个无限循环体里面,所以意味着,每来一个请求它就会开一个 goroutine 去处理,相当任性粗暴啊…,不过有 Go 调度器背书,一般来说也没啥压力,然而,如果,我是说如果哈,突然一大波请求涌进来了(比方说黑客搞了成千上万的肉鸡 DDOS 你,没错!就这么倒霉!),这时候,就很成问题了,他来 10w 个请求你就要开给他 10w 个 goroutine,来 100w 个你就要老老实实开给他 100w 个,线程调度压力陡升,内存爆满,再然后,你就跪了…
有问题,就一定有解决的办法,那么,有什么方案可以减缓大规模 goroutine 对系统的调度和内存压力?要想解决问题,最重要的是找到造成问题的根源,这个问题根源是什么?goroutine 的数量过多导致资源侵占,那要解决这个问题就要限制运行的 goroutine 数量,合理复用,节省资源,具体就是 — goroutine 池化。
超大规模并发的场景下,不加限制的大规模的 goroutine 可能造成内存暴涨,给机器带来极大的压力,吞吐量下降和处理速度变慢还是其次,更危险的是可能使得程序 crash。所以,goroutine 池化是有其现实意义的。
首先,100w 个任务,是不是真的需要 100w 个 goroutine 来处理?未必!用 1w 个 goroutine 也一样可以处理,让一个 goroutine 多处理几个任务就是了嘛,池化的核心优势就在于对 goroutine 的复用。此举首先极大减轻了 runtime 调度 goroutine 的压力,其次,便是降低了对内存的消耗。
有一个商场,来了 1000 个顾客买东西,那么该如何安排导购员服务这 1000 人呢?有两种方案:
第一,我雇 1000 个导购员实行一对一服务,这种当然是最高效的,但是太浪费资源了,雇 1000 个人的成本极高且管理困难,这些可以先按下不表,但是每个顾客到商场买东西也不是一进来就马上买,一般都得逛一逛,选一选,也就是得花时间挑,1000 个导购员一对一盯着,效率极低;这就引出第二种方案:我只雇 10 个导购员,就在商场里待命,有顾客需要咨询的时候招呼导购员过去进行处理,导购员处理完之后就回来,等下一个顾客需要咨询的时候再去,如此往返反复…
第二种方案有没有觉得很眼熟?没错,其基本思路就是模拟一个 I/O 多路复用,通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。关于多路复用,不在本文的讨论范围之内,便不再赘述,详细原理可以参考 I/O 多路复用。
第一种方案就是 net/http 标准库采用的:来一个请求开一个 goroutine 处理;第二种方案就是 Goroutine Pool(I/O 多路复用)。
因为上述陈列的一些由于 goroutine 规模过大而可能引发的问题,需要有方案来解决这些问题,上文已经分析过,把 goroutine 池化是一种行之有效的方案,基于此,可以实现一个 Goroutine Pool,复用 goroutine,减轻 runtime 的调度压力以及缓解内存压力,依托这些优化,在大规模 goroutine 并发的场景下可以极大地提高并发性能。
哎玛!前面絮絮叨叨了这么多,终于进入正题了,接下来就开始讲解如何实现一个高性能的 Goroutine Pool,秒杀原生并发的 goroutine,在执行速度和占用内存上提高并发程序的性能。好了,话不多说,开始
装逼分析。
Goroutine Pool 的实现思路大致如下:
启动服务之时先初始化一个 Goroutine Pool 池,这个 Pool 维护了一个类似栈的 LIFO 队列 ,里面存放负责处理任务的 Worker,然后在 client 端提交 task 到 Pool 中之后,在 Pool 内部,接收 task 之后的核心操作是:
- 检查当前 Worker 队列中是否有可用的 Worker,如果有,取出执行当前的 task;
- 没有可用的 Worker,判断当前在运行的 Worker 是否已超过该 Pool 的容量:{是 —> 再判断工作池是否为非阻塞模式:[是 ——> 直接返回 nil,否 ——> 阻塞等待直至有 Worker 被放回 Pool],否 —> 新开一个 Worker(goroutine)处理};
- 每个 Worker 执行完任务之后,放回 Pool 的队列中等待。
核心调度流程如下:
按照这个设计思路,我实现了一个高性能的 Goroutine Pool,较好地解决了上述的大规模调度和资源占用的问题,在执行速度和内存占用方面相较于原生 goroutine 并发占有明显的优势,尤其是内存占用,因为复用,所以规避了无脑启动大规模 goroutine 的弊端,可以节省大量的内存。
此外,该调度系统还有一个清理过期 Worker 的定时任务,该任务在初始化一个 Pool 之时启动,每隔一定的时间间隔去检查空闲 Worker 队列中是否有已经过期的 Worker,有则清理掉,通过定时清理过期 worker,进一步节省系统资源。
完整的项目代码可以在我的 GitHub 上获取:传送门,也欢迎提意见和交流。
Goroutine Pool 的设计原理前面已经讲过了,整个调度过程相信大家应该可以理解了,但是有一句老话说得好,空谈误国,实干兴邦,设计思路有了,具体实现的时候肯定会有很多细节、难点,接下来我们通过分析这个 Goroutine Pool 的几个核心实现以及它们的联动来引导大家过一遍 Goroutine Pool 的原理。
Pool struct
:1 | type sig struct{} |
Pool
是一个通用的协程池,支持不同类型的任务,亦即每一个任务绑定一个函数提交到池中,批量执行不同类型任务,是一种广义的协程池;本项目中还实现了另一种协程池 — 批量执行同类任务的协程池 PoolWithFunc
,每一个 PoolWithFunc
只会绑定一个任务函数 pf
,这种 Pool 适用于大批量相同任务的场景,因为每个 Pool 只绑定一个任务函数,因此 PoolWithFunc
相较于 Pool
会更加节省内存,但通用性就不如前者了,为了让大家更好地理解协程池的原理,这里我们用通用的 Pool
来分析。capacity
是该 Pool 的容量,也就是开启 worker 数量的上限,每一个 worker 绑定一个 goroutine; running
是当前正在执行任务的 worker 数量; expiryDuration
是 worker 的过期时长,在空闲队列中的 worker 的最新一次运行时间与当前时间之差如果大于这个值则表示已过期,定时清理任务会清理掉这个 worker; workers
是一个 slice,用来存放空闲 worker,请求进入 Pool 之后会首先检查 workers
中是否有空闲 worker,若有则取出绑定任务执行,否则判断当前运行的 worker 是否已经达到容量上限,是—阻塞等待,否—新开一个 worker 执行任务; release
是当关闭该 Pool 支持通知所有 worker 退出运行以防 goroutine 泄露; lock
是一个锁,用以支持 Pool 的同步操作; once
用在确保 Pool 关闭操作只会执行一次。
1 | // NewPool generates a instance of ants pool |
p.Submit(task f)
如下:
1 | // Submit submit a task to pool |
第一个 if 判断当前 Pool 是否已被关闭,若是则不再接受新任务,否则获取一个 Pool 中可用的 worker,绑定该 task
执行。
p.getWorker()
源码:
1 | // getWorker returns a available worker to run the tasks. |
上面的源码中加了较为详细的注释,结合前面的设计思路,相信大家应该能理解获取可用 worker 绑定任务执行这个协程池的核心操作,主要就是实现一个 LIFO 队列用来存取可用 worker 达到资源复用的效果,之所以采用 LIFO 后进先出队列是因为后进先出可以保证空闲 worker 队列是按照每个 worker 的最后运行时间从远到近的顺序排列,方便在后续定期清理过期 worker 时排序以及清理完之后重新分配空闲 worker 队列,这里还要关注一个地方:达到 Pool 容量限制之后,额外的任务请求需要阻塞等待 idle worker,这里是为了防止无节制地创建 goroutine,事实上 Go 调度器有一个复用机制,每次使用 go
关键字的时候它会检查当前结构体 M 中的 P 中,是否有可用的结构体 G。如果有,则直接从中取一个,否则,需要分配一个新的结构体 G。如果分配了新的 G,需要将它挂到 runtime 的相关队列中,但是调度器却没有限制 goroutine 的数量,这在瞬时性 goroutine 爆发的场景下就可能来不及复用 G 而依然创建了大量的 goroutine,所以 ants
除了复用还做了限制 goroutine 数量。
其他部分可以依照注释理解,这里不再赘述。
1 | 1// Worker is the actual executor who runs the tasks, |
结合前面的 p.Submit(task f)
和 p.getWorker()
,提交任务到 Pool 之后,获取一个可用 worker,每新建一个 worker 实例之时都需要调用 w.run()
启动一个 goroutine 监听 worker 的任务列表 task
,一有任务提交进来就执行;所以,当调用 worker 的 sendTask(task f)
方法提交任务到 worker 的任务队列之后,马上就可以被接收并执行,当任务执行完之后,会调用 w.pool.putWorker(w *Worker)
方法将这个已经执行完任务的 worker 从当前任务解绑放回 Pool 中,以供下个任务可以使用,至此,一个任务从提交到完成的过程就此结束,Pool 调度将进入下一个循环。
1 | 1// putWorker puts a worker back into free pool, recycling the goroutines. |
1 | 1// ReSize change the capacity of this pool |
1 | 1// clear expired workers periodically. |
定期检查空闲 worker 队列中是否有已过期的 worker 并清理:因为采用了 LIFO 后进先出队列存放空闲 worker,所以该队列默认已经是按照 worker 的最后运行时间由远及近排序,可以方便地按顺序取出空闲队列中的每个 worker 并判断它们的最后运行时间与当前时间之差是否超过设置的过期时长,若是,则清理掉该 goroutine,释放该 worker,并且将剩下的未过期 worker 重新分配到当前 Pool 的空闲 worker 队列中,进一步节省系统资源。
概括起来, ants
Goroutine Pool 的调度过程图示如下:
还记得前面我说除了通用的 Pool struct
之外,本项目还实现了一个 PoolWithFunc struct
—一个执行批量同类任务的协程池, PoolWithFunc
相较于 Pool
,因为一个池只绑定一个任务函数,省去了每一次 task 都需要传送一个任务函数的代价,因此其性能优势比起 Pool
更明显,这里我们稍微讲一下一个协程池只绑定一个任务函数的细节:
上码!
1 | 1type pf func(interface{}) error |
PoolWithFunc struct
中的大部分字段和 Pool struct
基本一致,重点关注 poolFunc pf
,这是一个函数类型,也就是该 Pool 绑定的指定任务函数,而 client 提交到这种类型的 Pool 的数据就不再是一个任务函数 task f
了,而是 poolFunc pf
任务函数的形参,然后交由 WorkerWithFunc
处理:
1 | 1// WorkerWithFunc is the actual executor who runs the tasks, |
上面的源码可以看到 WorkerWithFunc
是一个类似 Worker
的结构,只不过监听的是函数的参数队列,每接收到一个参数包,就直接调用 PoolWithFunc
绑定好的任务函数 poolFunc pf
任务函数执行任务,接下来的流程就和 Worker
是一致的了,执行完任务后就把 worker 放回协程池,等待下次使用。
至于其他逻辑如提交 task
、获取 Worker
绑定任务等基本复用自 Pool struct
,具体细节有细微差别,但原理一致,万变不离其宗,有兴趣的同学可以看我在 GitHub 上的源码:Goroutine Pool 协程池 ants 。
吹了这么久的 Goroutine Pool,那都是虚的,理论上池化可以复用 goroutine,提升性能节省内存,没有 benchmark 数据之前,好像也不能服众哈!所以,本章就来进行一次实测,验证一下再大规模 goroutine 并发的场景下,Goroutine Pool 的表现是不是真的比原生 Goroutine 并发更好!
测试机器参数:
测试代码传送门
测试结果:
这里为了模拟大规模 goroutine 的场景,两次测试的并发次数分别是 100w 和 1000w,前两个测试分别是执行 100w 个并发任务不使用 Pool 和使用了 ants
的 Goroutine Pool 的性能,后两个则是 1000w 个任务下的表现,可以直观的看出在执行速度和内存使用上, ants
的 Pool 都占有明显的优势。100w 的任务量,使用 ants
,执行速度与原生 goroutine 相当甚至略快,但只实际使用了不到 5w 个 goroutine 完成了全部任务,且内存消耗仅为原生并发的 40%;而当任务量达到 1000w,优势则更加明显了:用了 70w 左右的 goroutine 完成全部任务,执行速度比原生 goroutine 提高了 100%,且内存消耗依旧保持在不使用 Pool 的 40% 左右。
测试代码传送门
测试结果:
基准测试函数名-GOMAXPROCS
,后面的-4 代表测试函数运行时对应的 CPU 核数因为 PoolWithFunc
这个 Pool 只绑定一个任务函数,也即所有任务都是运行同一个函数,所以相较于 Pool
对原生 goroutine 在执行速度和内存消耗的优势更大,上面的结果可以看出,执行速度可以达到原生 goroutine 的 300%,而内存消耗的优势已经达到了两位数的差距,原生 goroutine 的内存消耗达到了 ants
的 35 倍且原生 goroutine 的每次执行的内存分配次数也达到了 ants
45 倍,1000w 的任务量, ants
的初始分配容量是 5w,因此它完成了所有的任务依旧只使用了 5w 个 goroutine!事实上, ants
的 Goroutine Pool 的容量是可以自定义的,也就是说使用者可以根据不同场景对这个参数进行调优直至达到最高性能。
上面的 benchmarks 出来以后,我当时的内心是这样的:
但是太顺利反而让我疑惑,因为结合我过去这 20 几年的坎坷人生来看,事情应该不会这么美好才对,果不其然,细细一想,虽然 ants
Groutine Pool 能在大规模并发下执行速度和内存消耗都对原生 goroutine 占有明显优势,但前面的测试 demo 相信大家注意到了,里面使用了 WaitGroup,也就是用来对 goroutine 同步的工具,所以上面的 benchmarks 中主进程会等待所有子 goroutine 完成任务后才算完成一次性能测试,然而又有多少场景是单台机器需要扛 100w 甚至 1000w 同步任务的?基本没有啊!结果就是造出了屠龙刀,可是世界上没有龙啊!也是无情…
彼时,我内心变成了这样:
幸好, ants
在同步批量任务方面有点曲高和寡,但是如果是异步批量任务的场景下,就有用武之地了,也就是说,在大批量的任务无须同步等待完成的情况下,可以再测一下 ants
和原生 goroutine 并发的性能对比,这个时候的性能对比也即是吞吐量对比了,就是在相同大规模数量的请求涌进来的时候, ants
和原生 goroutine 谁能用更快的速度、更少的内存『吞』完这些请求。
测试代码传送门
测试结果:
因为在我的电脑上测试 1000w 吞吐量的时候原生 goroutine 已经到了极限,因此程序直接把电脑拖垮了,无法正常测试了,所以 1000w 吞吐的测试数据只有 ants
Pool 的。
从该 demo 测试吞吐性能对比可以看出,使用ants
的吞吐性能相较于原生 goroutine 可以保持在 26 倍的性能压制,而内存消耗则可以达到 1020 倍的节省优势。
至此,一个高性能的 Goroutine Pool 开发就完成了,事实上,原理不难理解,总结起来就是一个『复用』,具体落实到代码细节就是锁同步、原子操作、channel 通信等这些技巧的使用, ant
这整个项目没有借助任何第三方的库,用 golang 的标准库就完成了所有功能,因为本身 golang 的语言原生库已经足够优秀,很多时候开发 golang 项目的时候是可以保持轻量且高性能的,未必事事需要借助第三方库。
关于 ants
的价值,其实前文也提及过了, ants
在大规模的异步&同步批量任务处理都有着明显的性能优势(特别是异步批量任务),而单机上百万上千万的同步批量任务处理现实意义不大,但是在异步批量任务处理方面有很大的应用价值,所以我个人觉得,Goroutine Pool 真正的价值还是在:
Go 语言的三位最初的缔造者 — Rob Pike、Robert Griesemer 和 Ken Thompson 中,Robert Griesemer 参与设计了 Java 的 HotSpot 虚拟机和 Chrome 浏览器的 JavaScript V8 引擎,Rob Pike 在大名鼎鼎的 bell lab 侵淫多年,参与了 Plan9 操作系统、C 编译器以及多种语言编译器的设计和实现,Ken Thompson 更是图灵奖得主、Unix 之父、C 语言之父。这三人在计算机史上可是元老级别的人物,特别是 Ken Thompson ,是一手缔造了 Unix 和 C 语言计算机领域的上古大神,所以 Go 语言的设计哲学有着深深的 Unix 烙印:简单、模块化、正交、组合、pipe、功能短小且聚焦等;而令许多开发者青睐于 Go 的简洁、高效编程模式的原因,也正在于此。
本文从三大线程模型到 Go 并发调度器再到自定制的 Goroutine Pool,算是较为完整的窥探了整个 Go 语言并发模型的前世今生,我们也可以看到,Go 的设计当然不完美,比如一直被诟病的 error 处理模式、不支持泛型、差强人意的包管理以及面向对象模式的过度抽象化等等,实际上没有任何一门编程语言敢说自己是完美的,还是那句话,任何不考虑应用场景和语言定位的争执都毫无意义,而 Go 的定位从出道开始就是系统编程语言&云计算编程语言(这个有点模糊),而 Go 的作者们也一直坚持的是用最简单抽象的工程化设计完成最复杂的功能,所以如果从这个层面去看 Go 的并发模型,就可以看出其实除了 G-P-M 模型中引入的 P ,并没有太多革新的原创理论,两级线程模型是早已成熟的理论,抢占式调度更不是什么新鲜的调度模式,Go 的伟大之处是在于它诞生之初就是依照Go 在谷歌:以软件工程为目的的语言设计而设计的,Go 其实就是将这些经典的理论和技术以一种优雅高效的工程化方式组合了起来,并用简单抽象的 API 或语法糖开放给使用者,Go 一直致力于找寻一个高性能&开发效率的双赢点,目前为止,它做得远不够完美,但足够优秀。另外 Go 通过引入 channel 与 goroutine 协同工作,将一种区别于锁&原子操作的并发编程模式 — CSP 带入了 Go 语言,对开发人员在并发编程模式上的思考有很大的启发。
从本文中对 Go 调度器的分析以及 ants
Goroutine Pool 的设计与实现过程,对 Go 的并发模型做了一次解构和优化思考,在 ants
中的代码实现对锁同步、原子操作、channel 通信的使用也做了一次较为全面的实践,希望对 Gopher 们在 Go 语言并发模型与并发编程的理解上能有所裨益。
感谢阅读。
1、对查询进行优化,应尽量避免全表扫描,首先应考虑在 WHERE 及 ORDER BY 涉及的列上建立索引。
2、应尽量避免在 WHERE 子句中对字段进行 NULL 值判断,创建表时 NULL 是默认值,但大多数时候应该使用 NOT NULL,或者使用一个特殊的值,如 0,-1 作为默认值。
3、应尽量避免在 WHERE 子句中使用 != 或 <> 操作符。MySQL 只有对以下操作符才使用索引:<,<=,=,>,>=,BETWEEN,IN,以及某些时候的 LIKE。
4、应尽量避免在 WHERE 子句中使用 OR 来连接条件,否则将导致引擎放弃使用索引而进行全表扫描,可以使用 UNION 合并查询:select id from t where num=10 union all select id from t where num=20。
5、IN 和 NOT IN 也要慎用,否则会导致全表扫描。对于连续的数值,能用 BETWEEN 就不要用 IN:select id from t where num between 1 and 3。
6、下面的查询也将导致全表扫描:select id from t where name like‘%abc%’ 或者 select id from t where name like‘%abc’若要提高效率,可以考虑全文检索。而 select id from t where name like‘abc%’才用到索引。
7、如果在 WHERE 子句中使用参数,也会导致全表扫描。
8、应尽量避免在 WHERE 子句中对字段进行表达式操作,应尽量避免在 WHERE 子句中对字段进行函数操作。
9、很多时候用 EXISTS 代替 IN 是一个好的选择:select num from a where num in(select num from b)。用下面的语句替换:select num from a where exists(select 1 from b where num=a.num)。
10、索引固然可以提高相应的 SELECT 的效率,但同时也降低了 INSERT 及 UPDATE 的效。因为 INSERT 或 UPDATE 时有可能会重建索引,所以怎样建索引需要慎重考虑,视具体情况而定。一个表的索引数最好不要超过 6 个,若太多则应考虑一些不常使用到的列上建的索引是否有必要。
11、应尽可能的避免更新 clustered 索引数据列, 因为 clustered 索引数据列的顺序就是表记录的物理存储顺序,一旦该列值改变将导致整个表记录的顺序的调整,会耗费相当大的资源。若应用系统需要频繁更新 clustered 索引数据列,那么需要考虑是否应将该索引建为 clustered 索引。
12、尽量使用数字型字段,若只含数值信息的字段尽量不要设计为字符型,这会降低查询和连接的性能,并会增加存储开销。
13、尽可能的使用 varchar, nvarchar 代替 char, nchar。因为首先变长字段存储空间小,可以节省存储空间,其次对于查询来说,在一个相对较小的字段内搜索效率显然要高些。
14、最好不要使用返回所有:select from t ,用具体的字段列表代替 “*”,不要返回用不到的任何字段。
15、尽量避免向客户端返回大数据量,若数据量过大,应该考虑相应需求是否合理。
16、使用表的别名(Alias):当在 SQL 语句中连接多个表时,请使用表的别名并把别名前缀于每个 Column 上。这样一来,就可以减少解析的时间并减少那些由 Column 歧义引起的语法错误。
17、使用“临时表”暂存中间结果 :
简化 SQL 语句的重要方法就是采用临时表暂存中间结果。但是临时表的好处远远不止这些,将临时结果暂存在临时表,后面的查询就在 tempdb 中了,这可以避免程序中多次扫描主表,也大大减少了程序执行中“共享锁”阻塞“更新锁”,减少了阻塞,提高了并发性能。
18、一些 SQL 查询语句应加上 nolock,读、写是会相互阻塞的,为了提高并发性能。对于一些查询,可以加上 nolock,这样读的时候可以允许写,但缺点是可能读到未提交的脏数据。
使用 nolock 有 3 条原则:
19、常见的简化规则如下:
不要有超过 5 个以上的表连接(JOIN),考虑使用临时表或表变量存放中间结果。少用子查询,视图嵌套不要过深,一般视图嵌套不要超过 2 个为宜。
20、将需要查询的结果预先计算好放在表中,查询的时候再 Select。这在 SQL7.0 以前是最重要的手段,例如医院的住院费计算。
21、用 OR 的字句可以分解成多个查询,并且通过 UNION 连接多个查询。他们的速度只同是否使用索引有关,如果查询需要用到联合索引,用 UNION all 执行的效率更高。多个 OR 的字句没有用到索引,改写成 UNION 的形式再试图与索引匹配。一个关键的问题是否用到索引。
22、在 IN 后面值的列表中,将出现最频繁的值放在最前面,出现得最少的放在最后面,减少判断的次数。
23、尽量将数据的处理工作放在服务器上,减少网络的开销,如使用存储过程。
存储过程是编译好、优化过、并且被组织到一个执行规划里、且存储在数据库中的 SQL 语句,是控制流语言的集合,速度当然快。反复执行的动态 SQL,可以使用临时存储过程,该过程(临时表)被放在 Tempdb 中。
24、当服务器的内存够多时,配制线程数量 = 最大连接数+5,这样能发挥最大的效率;否则使用配制线程数量< 最大连接数,启用 SQL SERVER 的线程池来解决,如果还是数量 = 最大连接数+5,严重的损害服务器的性能。
25、查询的关联同写的顺序 :
1 | select a.personMemberID, * from chineseresume a,personmember b where personMemberID = b.referenceid and a.personMemberID = 'JCNPRH39681' (A = B, B = '号码') select a.personMemberID, * from chineseresume a,personmember b where a.personMemberID = b.referenceid and a.personMemberID = 'JCNPRH39681' and b.referenceid = 'JCNPRH39681' (A = B, B = '号码', A = '号码') select a.personMemberID, * from chineseresume a,personmember b where b.referenceid = 'JCNPRH39681' and a.personMemberID = 'JCNPRH39681' (B = '号码', A = '号码') |
26、尽量使用 EXISTS 代替 select count(1) 来判断是否存在记录。count 函数只有在统计表中所有行数时使用,而且 count(1) 比 count(*) 更有效率。
27、尽量使用 “>=”,不要使用 “>”。
28、索引的使用规范:
29、下列 SQL 条件语句中的列都建有恰当的索引,但执行速度却非常慢:
1 | SELECT * FROM record WHERE substrINg(card_no, 1, 4) = '5378' --13秒 SELECT * FROM record WHERE amount/30 < 1000 --11秒 SELECT * FROM record WHERE convert(char(10), date, 112) = '19991201' --10秒 |
分析:
WHERE 子句中对列的任何操作结果都是在 SQL 运行时逐列计算得到的,因此它不得不进行表搜索,而没有使用该列上面的索引。
如果这些结果在查询编译时就能得到,那么就可以被 SQL 优化器优化,使用索引,避免表搜索,因此将 SQL 重写成下面这样:
1 | SELECT * FROM record WHERE card_no like '5378%' -- < 1秒 SELECT * FROM record WHERE amount < 1000*30 -- < 1秒 SELECT * FROM record WHERE date = '1999/12/01' -- < 1秒 |
30、当有一批处理的插入或更新时,用批量插入或批量更新,绝不会一条条记录的去更新。
31、在所有的存储过程中,能够用 SQL 语句的,我绝不会用循环去实现。
例如:列出上个月的每一天,我会用 connect by 去递归查询一下,绝不会去用循环从上个月第一天到最后一天。
32、选择最有效率的表名顺序(只在基于规则的优化器中有效):
Oracle 的解析器按照从右到左的顺序处理 FROM 子句中的表名,FROM 子句中写在最后的表(基础表 driving table)将被最先处理,在 FROM 子句中包含多个表的情况下,你必须选择记录条数最少的表作为基础表。
如果有 3 个以上的表连接查询,那就需要选择交叉表(intersection table)作为基础表,交叉表是指那个被其他表所引用的表。
33、提高 GROUP BY 语句的效率,可以通过将不需要的记录在 GROUP BY 之前过滤掉。下面两个查询返回相同结果,但第二个明显就快了许多。
低效:
1 | SELECT JOB, AVG(SAL) FROM EMP GROUP BY JOB HAVING JOB = 'PRESIDENT' OR JOB = 'MANAGER' |
高效:
1 | SELECT JOB, AVG(SAL) FROM EMPWHERE JOB = 'PRESIDENT' OR JOB = 'MANAGER' GROUP BY JOB |
34、SQL 语句用大写,因为 Oracle 总是先解析 SQL 语句,把小写的字母转换成大写的再执行。
35、别名的使用,别名是大型数据库的应用技巧,就是表名、列名在查询中以一个字母为别名,查询速度要比建连接表快 1.5 倍。
36、避免死锁,在你的存储过程和触发器中访问同一个表时总是以相同的顺序;事务应经可能地缩短,在一个事务中应尽可能减少涉及到的数据量;永远不要在事务中等待用户输入。
37、避免使用临时表,除非却有需要,否则应尽量避免使用临时表,相反,可以使用表变量代替。大多数时候(99%),表变量驻扎在内存中,因此速度比临时表更快,临时表驻扎在 TempDb 数据库中,因此临时表上的操作需要跨数据库通信,速度自然慢。
38、最好不要使用触发器:
39、索引创建规则:
40、MySQL 查询优化总结:
使用慢查询日志去发现慢查询,使用执行计划去判断查询是否正常运行,总是去测试你的查询看看是否他们运行在最佳状态下。
久而久之性能总会变化,避免在整个表上使用 count(*),它可能锁住整张表,使查询保持一致以便后续相似的查询可以使用查询缓存,在适当的情形下使用 GROUP BY 而不是 DISTINCT,在 WHERE、GROUP BY 和 ORDER BY 子句中使用有索引的列,保持索引简单,不在多个索引中包含同一个列。
有时候 MySQL 会使用错误的索引,对于这种情况使用 USE INDEX,检查使用 SQL_MODE=STRICT 的问题,对于记录数小于 5 的索引字段,在 UNION 的时候使用 LIMIT 不是是用 OR。
为了避免在更新前 SELECT,使用 INSERT ON DUPLICATE KEY 或者 INSERT IGNORE;不要用 UPDATE 去实现,不要使用 MAX;使用索引字段和 ORDER BY 子句 LIMIT M,N 实际上可以减缓查询在某些情况下,有节制地使用,在 WHERE 子句中使用 UNION 代替子查询,在重新启动的 MySQL,记得来温暖你的数据库,以确保数据在内存和查询速度快,考虑持久连接,而不是多个连接,以减少开销。
基准查询,包括使用服务器上的负载,有时一个简单的查询可以影响其他查询,当负载增加在服务器上,使用 SHOW PROCESSLIST 查看慢的和有问题的查询,在开发环境中产生的镜像数据中测试的所有可疑的查询。
41、MySQL 备份过程:
42、查询缓冲并不自动处理空格,因此,在写 SQL 语句时,应尽量减少空格的使用,尤其是在 SQL 首和尾的空格(因为查询缓冲并不自动截取首尾空格)。
43、member 用 mid 做标准进行分表方便查询么?一般的业务需求中基本上都是以 username 为查询依据,正常应当是 username 做 hash 取模来分表。
而分表的话 MySQL 的 partition 功能就是干这个的,对代码是透明的;在代码层面去实现貌似是不合理的。
44、我们应该为数据库里的每张表都设置一个 ID 做为其主键,而且最好的是一个 INT 型的(推荐使用 UNSIGNED),并设置上自动增加的 AUTO_INCREMENT 标志。
45、在所有的存储过程和触发器的开始处设置 SET NOCOUNT ON,在结束时设置 SET NOCOUNT OFF。无需在执行存储过程和触发器的每个语句后向客户端发送 DONE_IN_PROC 消息。
46、MySQL 查询可以启用高速查询缓存。这是提高数据库性能的有效 MySQL 优化方法之一。当同一个查询被执行多次时,从缓存中提取数据和直接从数据库中返回数据快很多。
47、EXPLAIN SELECT 查询用来跟踪查看效果:
使用 EXPLAIN 关键字可以让你知道 MySQL 是如何处理你的 SQL 语句的。这可以帮你分析你的查询语句或是表结构的性能瓶颈。EXPLAIN 的查询结果还会告诉你你的索引主键被如何利用的,你的数据表是如何被搜索和排序的。
48、当只要一行数据时使用 LIMIT 1 :
当你查询表的有些时候,你已经知道结果只会有一条结果,但因为你可能需要去 fetch 游标,或是你也许会去检查返回的记录数。
在这种情况下,加上 LIMIT 1 可以增加性能。这样一来,MySQL 数据库引擎会在找到一条数据后停止搜索,而不是继续往后查少下一条符合记录的数据。
49、选择表合适存储引擎:
myisam:应用时以读和插入操作为主,只有少量的更新和删除,并且对事务的完整性,并发性要求不是很高的。
InnoDB:事务处理,以及并发条件下要求数据的一致性。除了插入和查询外,包括很多的更新和删除。(InnoDB 有效地降低删除和更新导致的锁定)。
对于支持事务的 InnoDB 类 型的表来说,影响速度的主要原因是 AUTOCOMMIT 默认设置是打开的,而且程序没有显式调用 BEGIN 开始事务,导致每插入一条都自动提交,严重影响了速度。可以在执行 SQL 前调用 begin,多条 SQL 形成一个事物(即使 autocommit 打开也可以),将大大提高性能。
50、优化表的数据类型,选择合适的数据类型:
原则:更小通常更好,简单就好,所有字段都得有默认值,尽量避免 NULL。
例如:数据库表设计时候更小的占磁盘空间尽可能使用更小的整数类型。(mediumint 就比 int 更合适)
比如时间字段:datetime 和 timestamp。datetime 占用 8 个字节,timestamp 占用 4 个字节,只用了一半。而 timestamp 表示的范围是 1970—2037 适合做更新时间。
MySQL 可以很好的支持大数据量的存取,但是一般说来,数据库中的表越小,在它上面执行的查询也就会越快。
因此,在创建表的时候,为了获得更好的性能,我们可以将表中字段的宽度设得尽可能小。
例如:在定义邮政编码这个字段时,如果将其设置为 CHAR(255),显然给数据库增加了不必要的空间。甚至使用 VARCHAR 这种类型也是多余的,因为 CHAR(6) 就可以很好的完成任务了。
同样的,如果可以的话,我们应该使用 MEDIUMINT 而不是 BIGIN 来定义整型字段,应该尽量把字段设置为 NOT NULL,这样在将来执行查询的时候,数据库不用去比较 NULL 值。
对于某些文本字段,例如“省份”或者“性别”,我们可以将它们定义为 ENUM 类型。因为在 MySQL 中,ENUM 类型被当作数值型数据来处理,而数值型数据被处理起来的速度要比文本类型快得多。这样,我们又可以提高数据库的性能。
51、字符串数据类型:char, varchar, text 选择区别。
52、任何对列的操作都将导致表扫描,它包括数据库函数、计算表达式等等,查询时要尽可能将操作移至等号右边。
https://chai2010.cn/advanced-go-programming-book/ch4-rpc/ch4-01-rpc-intro.html
https://wskdsgcf.gitbook.io/mastering-go-zh-cn/ 3.基础知识
https://github.com/CyC2018/CS-Notes 4.规范
https://github.com/nusr/hacker-laws-zh#%e5%a5%a5%e5%8d%a1%e5%a7%86%e5%89%83%e5%88%80-occams-razor
5.leetcode 题解
https://books.halfrost.com/leetcode/
6.murphyyi 书籍
http://godoc.murphyyi.com/
7. 必看网站
https://github.com/yanglbme/redis-multi-programming-language-practice
面试
https://www.yuque.com/fz420/smym5g/gy5h6b
好事情
缺失模块。
1、请确保node版本大于6.2
2、在博客根目录(注意不是yilia根目录)执行以下命令:
npm i hexo-generator-json-content --save
3、在根目录_config.yml里添加配置:
jsonContent: meta: false pages: false posts: title: true date: true path: true text: false raw: false content: false slug: false updated: false comments: false link: false permalink: false excerpt: false categories: false tags: true