标签 Go 下的文章

Hello,WireGuard

2020年1月28日,Linux之父Linus Torvalds正式将WireGuard merge到Linux 5.6版本内核主线

img{512x368}

图:WireGuard被加入linux kernel 5.6主线的commit log

这意味着在Linux 5.6内核发布时,linux在内核层面将原生支持一个新的VPN协议栈:WireGuard

img{512x368}

图:WireGuard Logo

一. VPN与WireGuard的创新

VPN,全称Virtual Private Network(虚拟专用网络)。提起VPN,大陆的朋友想到的第一件事就是fan qiang。其实fan qiang只是VPN的一个“小众”应用罢了^_^,企业网络才是VPN真正施展才能的地方。VPN支持在不安全的公网上建立一条加密的、安全的到企业内部网络的通道(隧道tunnel),这就好比专门架设了一个专用网络那样。在WireGuard出现之前,VPN的隧道协议主要有PPTPL2TPIPSec等,其中PPTP和L2TP协议工作在OSI模型的第二层,又称为二层隧道协议;IPSec是第三层隧道协议。

既然已经有了这么多的VPN协议,那么Why WireGuard?

WireGuard的作者Jason A. DonenfeldWireGuard官网给出了很明确地理由:

  • 简单、易用、无连接、无状态:号称目前最易用和最简单的VPN解决方案

WireGuard可以像SSH一样易于配置和部署。只需交换非常简单的公钥就可以建立VPN连接,就像交换SSH密钥一样,其余所有由WireGuard透明处理。并且WireGuard建立的VPN连接是基于UDP的,无需建立和管理连接,无需关心和管理状态的。

  • 先进加密协议

WireGuard充分利用安全领域和密码学在这些年的最新成果,使用noise frameworkCurve25519ChaCha20Poly1305BLAKE2SipHash24等构建WireGuard的安全方案。

  • 最小的攻击面(最少代码实现)

WireGuard的内核模块c代码仅不足5k行,便于代码安全评审。也使得WireGuard的实现更不容易被攻击(代码量少,理论上漏洞相对于庞大的代码集合而言也会少许多)。

  • 高性能

密码学最新成果带来的高速机密原语和WireGuard的内核驻留机制,使其相较于之前的VPN方案更具性能优势。

以上这些理由,同时也是WireGuard这个协议栈的特性。

这么说依然很抽象,我们来实操一下,体验一下WireGuard的简洁、易用、安全、高效。

二. WireGuard安装和使用

WireGuard将在linux 5.6内核中提供原生支持,也就是说在那之前,我们还无法直接使用WireGuard,安装还是不可避免的。在我的实验环境中有两台Linux VPS主机,都是ubuntu 18.04,内核都是4.15.0。因此我们需要首先添加WireGuard的ppa仓库:

sudo add-apt-repository ppa:wireguard/wireguard

更新源后,即可通过下面命令安装WireGuard:

sudo apt-get update

sudo apt-get install wireguard

安装的WireGuard分为两部分:

  • WireGuard内核模块(wireguard.ko),这部分通过动态内核模块技术DKMS安装到ubuntu的内核模块文件目录下:
$ ls /lib/modules/4.15.0-29-generic/updates/dkms/
wireguard.ko

  • 用户层的命令行工具

类似于内核netfilter和命令行工具iptables之间关系,wireguard.ko对应的用户层命令行工具wireguard-tools:wg、wg-quick被安装到/usr/bin下面了:

$ ls -t /usr/bin|grep wg|head -n 2
wg
wg-quick

1. peer to peer vpn

在两个linux Vps上都安装完WireGuard后,我们就可以在两个节点(peer)建立虚拟专用网络(VPN)了。我们分为称两个linux节点为peer1和peer2:

img{512x368}

图:点对点wireguard通信图

就像上图那样,我们只分别需要在peer1和peer2建立/etc/wireguard/wg0.conf

peer1的/etc/wireguard/wg0.conf

[Interface]
PrivateKey = {peer1's privatekey}
Address = 10.0.0.1
ListenPort = 51820

[Peer]
PublicKey = {peer2's publickey}
EndPoint = {peer2's ip}:51820
AllowedIPs = 10.0.0.2/32

peer2的/etc/wireguard/wg0.conf

[Interface]
PrivateKey = {peer2's privatekey}
Address = 10.0.0.2
ListenPort = 51820

[Peer]
PublicKey = {peer1's publickey}
EndPoint = {peer1's ip}:51820
AllowedIPs = 10.0.0.1/32

我们看到每个peer上WireGuard所需的配置文件wg0.conf包含两大部分:

  • [Interface]部分

    • PrivateKey – peer自身的privatekey

    • Address – peer的wg0接口在vpn网络中绑定的路由ip范围,在上述例子中仅绑定了一个ip地址

    • ListenPort – wg网络协议栈监听UDP端口

  • [Peer]部分(描述vpn网中其他peer信息,一个wg0配置文件中显然可以配置多个Peer部分)

    • PublicKey – 该peer的publickey

    • EndPoint – 该peer的wg网路协议栈地址(ip+port)

    • AllowedIPs – 允许该peer发送过来的wireguard载荷中的源地址范围。同时本机而言,这个字段也会作为本机路由表中wg0绑定的ip范围。

每个Peer自身的privatekey和publickey可以通过WireGuard提供的命令行工具生成:

$ wg genkey | tee privatekey | wg pubkey > publickey
$ ls
privatekey  publickey

注:这两个文件可以生成在任意路径下,我们要的是两个文件中内容。

在两个peer上配置完/etc/wireguard/wg0.conf配置文件后,我们就可以使用下面命令在peer1和peer2之间建立一条双向加密VPN隧道了:

peer1:

$ sudo wg-quick up wg0
[#] ip link add wg0 type wireguard
[#] wg setconf wg0 /dev/fd/63
[#] ip -4 address add 10.0.0.1 dev wg0
[#] ip link set mtu 1420 up dev wg0
[#] ip -4 route add 10.0.0.2/32 dev wg0

peer2:

$ sudo wg-quick up wg0
[#] ip link add wg0 type wireguard
[#] wg setconf wg0 /dev/fd/63
[#] ip -4 address add 10.0.0.2 dev wg0
[#] ip link set mtu 1420 up dev wg0
[#] ip -4 route add 10.0.0.1/32 dev wg0

执行上述命令,每个peer会增加一个network interface dev: wg0,并在系统路由表中增加一条路由,以peer1为例:

$ ip a

... ...

4: wg0: <POINTOPOINT,NOARP,UP,LOWER_UP> mtu 1420 qdisc noqueue state UNKNOWN group default qlen 1000
    link/none
    inet 10.0.0.1/32 scope global wg0
       valid_lft forever preferred_lft forever

$ ip route
default via 172.21.0.1 dev eth0 proto dhcp metric 100
10.0.0.2 dev wg0 scope link
... ...

现在我们来测试两个Peer之间的连通性。WireGuard的peer之间是对等的,谁发起的请求谁就是client端。我们在peer1上ping peer2,在peer2上我们用tcpdump抓wg0设备的包:

Peer1:

$ ping -c 3 10.0.0.2
PING 10.0.0.2 (10.0.0.2) 56(84) bytes of data.
64 bytes from 10.0.0.2: icmp_seq=1 ttl=64 time=34.9 ms
64 bytes from 10.0.0.2: icmp_seq=2 ttl=64 time=34.7 ms
64 bytes from 10.0.0.2: icmp_seq=3 ttl=64 time=34.6 ms

--- 10.0.0.2 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2002ms
rtt min/avg/max/mdev = 34.621/34.781/34.982/0.262 ms

Peer2:

# tcpdump -i wg0
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on wg0, link-type RAW (Raw IP), capture size 262144 bytes
13:29:52.659550 IP 10.0.0.1 > instance-cspzrq3u: ICMP echo request, id 20580, seq 1, length 64
13:29:52.659603 IP instance-cspzrq3u > 10.0.0.1: ICMP echo reply, id 20580, seq 1, length 64
13:29:53.660463 IP 10.0.0.1 > instance-cspzrq3u: ICMP echo request, id 20580, seq 2, length 64
13:29:53.660495 IP instance-cspzrq3u > 10.0.0.1: ICMP echo reply, id 20580, seq 2, length 64
13:29:54.662201 IP 10.0.0.1 > instance-cspzrq3u: ICMP echo request, id 20580, seq 3, length 64
13:29:54.662234 IP instance-cspzrq3u > 10.0.0.1: ICMP echo reply, id 20580, seq 3, length 64

我们看到peer1和peer2经由WireGuard建立的vpn实现了连通:在peer2上ping peer1(10.0.0.1)亦得到相同结果。

这时如果我们如果在peer2(vpn ip: 10.0.0.2)上启动一个http server(监听0.0.0.0:9090):

//httpserver.go
package main

import "net/http"

func index(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("hello, wireguard\n"))
}

func main() {
    http.Handle("/", http.HandlerFunc(index))
    http.ListenAndServe(":9090", nil)
}

那么我们在peer1(vpn ip:10.0.0.1)去访问这个server:

$ curl http://10.0.0.2:9090
hello, wireguard

在peer2(instance-cspzrq3u)上的tcpdump显示(tcp握手+数据通信+tcp拆除):

14:15:05.233794 IP 10.0.0.1.43922 > instance-cspzrq3u.9090: Flags [S], seq 1116349511, win 27600, options [mss 1380,sackOK,TS val 3539789774 ecr 0,nop,wscale 7], length 0
14:15:05.233854 IP instance-cspzrq3u.9090 > 10.0.0.1.43922: Flags [S.], seq 3504538202, ack 1116349512, win 27360, options [mss 1380,sackOK,TS val 2842719516 ecr 3539789774,nop,wscale 7], length 0
14:15:05.268792 IP 10.0.0.1.43922 > instance-cspzrq3u.9090: Flags [.], ack 1, win 216, options [nop,nop,TS val 3539789809 ecr 2842719516], length 0
14:15:05.268882 IP 10.0.0.1.43922 > instance-cspzrq3u.9090: Flags [P.], seq 1:78, ack 1, win 216, options [nop,nop,TS val 3539789809 ecr 2842719516], length 77
14:15:05.268907 IP instance-cspzrq3u.9090 > 10.0.0.1.43922: Flags [.], ack 78, win 214, options [nop,nop,TS val 2842719551 ecr 3539789809], length 0
14:15:05.269514 IP instance-cspzrq3u.9090 > 10.0.0.1.43922: Flags [P.], seq 1:134, ack 78, win 214, options [nop,nop,TS val 2842719552 ecr 3539789809], length 133
14:15:05.304147 IP 10.0.0.1.43922 > instance-cspzrq3u.9090: Flags [.], ack 134, win 224, options [nop,nop,TS val 3539789845 ecr 2842719552], length 0
14:15:05.304194 IP 10.0.0.1.43922 > instance-cspzrq3u.9090: Flags [F.], seq 78, ack 134, win 224, options [nop,nop,TS val 3539789845 ecr 2842719552], length 0
14:15:05.304317 IP instance-cspzrq3u.9090 > 10.0.0.1.43922: Flags [F.], seq 134, ack 79, win 214, options [nop,nop,TS val 2842719586 ecr 3539789845], length 0
14:15:05.339035 IP 10.0.0.1.43922 > instance-cspzrq3u.9090: Flags [.], ack 135, win 224, options [nop,nop,TS val 3539789880 ecr 2842719586], length 0

如果要拆除这个vpn,只需在每个peer上分别执行如下命令:

$ sudo wg-quick down wg0
[#] ip link delete dev wg0

2. peer to the local network of other peer

上面两个peer虽然实现了点对点的连通,但是如果我们想从peer1访问peer2所在的局域网中的另外一台机器(这显然是vpn最常用的应用场景),如下面示意图:

img{512x368}

图:从一个peer到另外一个peer所在局域网的节点的通信图

基于目前的配置是否能实现呢?我们来试试。首先我们在peer1上要将192.168.1.0/24网段的路由指到wg0上,这样我们在peer1上ping或curl 192.168.1.123:9090,数据才能被交给wg0处理并通过vpn网络送出,修改peer1上的wg0.conf:

// peer1's /etc/wireguard/wg0.conf

... ...
[Peer]
PublicKey = {peer2's publickey}
EndPoint = peer2's ip:51820
AllowedIPs = 10.0.0.2/32,192.168.1.0/24

重启peer1上的wg0使上述配置生效。然后我们尝试在peer1上ping 192.168.1.123:

$ ping -c 3 192.168.1.123
PING 192.168.1.123 (192.168.1.123) 56(84) bytes of data.

--- 192.168.1.123 ping statistics ---
3 packets transmitted, 0 received, 100% packet loss, time 2038ms

我们在peer2上的tcpdump显示:

# tcpdump -i wg0
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on wg0, link-type RAW (Raw IP), capture size 262144 bytes
14:33:38.393520 IP 10.0.0.1 > 192.168.1.123: ICMP echo request, id 30426, seq 1, length 64
14:33:39.408083 IP 10.0.0.1 > 192.168.1.123: ICMP echo request, id 30426, seq 2, length 64
14:33:40.432079 IP 10.0.0.1 > 192.168.1.123: ICMP echo request, id 30426, seq 3, length 64

我们看到peer2收到来自10.0.0.1的到192.168.1.123的ping包都没有对应的回包,通信失败。Why?我们分析一下。

peer2在51820端口收到WireGuard包后,去除wireguard包的包裹,露出真实数据包。真实数据包的目的ip地址为192.168.1.123,该地址并非peer2自身地址(其自身局域网地址为192.168.1.10)。既然不是自身地址,就不能送到上层协议栈(tcp)处理,那么另外一条路是forward(转发)出去。但是是否允许转发么?显然从结果来看,从wg0收到的消息无权转发,于是消息丢弃,这就是没有回包和通信失败的原因。

为了支持转发(这是vpn常用场景的功能哦),我们需要为peer2的wg0.conf增加些转发配置:

// peer2's  wg0.conf

[Interface]

... ...
PostUp   = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUT  ING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUT  ING -o eth0 -j MASQUERADE

... ...

重启peer2的wg0。在peer2的内核层我们也要开启转发开关:

// /etc/sysctl.conf

net.ipv4.ip_forward=1

net.ipv6.conf.all.forwarding=1

执行下面命令临时生效:

# sysctl -p
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1

接下来,我们再来测试一下连通性。我们在peer1上再次尝试ping 192.168.1.123

$ ping -c 3 192.168.1.123
PING 192.168.1.123 (192.168.1.123) 56(84) bytes of data.
64 bytes from 192.168.1.123: icmp_seq=1 ttl=46 time=200 ms
64 bytes from 192.168.1.123: icmp_seq=2 ttl=46 time=200 ms
64 bytes from 192.168.1.123: icmp_seq=3 ttl=46 time=200 ms

--- 192.168.1.123 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2002ms
rtt min/avg/max/mdev = 200.095/200.239/200.396/0.531 ms

这回通了!peer2上的Tcpdump输出中也看到了回包:

14:49:58.808467 IP 10.0.0.1 > 192.168.1.123: ICMP echo request, id 402, seq 1, length 64
14:49:58.974035 IP 192.168.1.123 > 10.0.0.1: ICMP echo reply, id 402, seq 1, length 64
14:49:59.809747 IP 10.0.0.1 > 192.168.1.123: ICMP echo request, id 402, seq 2, length 64
14:49:59.975240 IP 192.168.1.123 > 10.0.0.1: ICMP echo reply, id 402, seq 2, length 64
14:50:00.810802 IP 10.0.0.1 > 192.168.1.123: ICMP echo request, id 402, seq 3, length 64
14:50:00.976202 IP 192.168.1.123 > 10.0.0.1: ICMP echo reply, id 402, seq 3, length 64

我们在192.168.1.123上运行上面的那个httpserver程序,再在peer1上用curl访问这个程序:

$ curl 192.168.1.123:9090
hello, wireguard

我们看到httpserver的应答成功返回。peer2上的tcpdump也抓到了整个通信过程:

14:50:36.437259 IP 10.0.0.1.47918 > 192.168.1.123.9090: Flags [S], seq 3235649864, win 27600, options [mss 1380,sackOK,TS val 101915019 ecr 0,nop,wscale 7], length 0
14:50:36.593554 IP 192.168.1.123.9090 > 10.0.0.1.47918: Flags [S.], seq 2420552016, ack 3235649865, win 28960, options [mss 1460,sackOK,TS val 2323314775 ecr 101915019,nop,wscale 7], length 0
14:50:36.628315 IP 10.0.0.1.47918 > 192.168.1.123.9090: Flags [.], ack 1, win 216, options [nop,nop,TS val 101915210 ecr 2323314775], length 0
14:50:36.628379 IP 10.0.0.1.47918 > 192.168.1.123.9090: Flags [P.], seq 1:84, ack 1, win 216, options [nop,nop,TS val 101915210 ecr 2323314775], length 83
14:50:36.784550 IP 192.168.1.123.9090 > 10.0.0.1.47918: Flags [.], ack 84, win 227, options [nop,nop,TS val 2323314822 ecr 101915210], length 0
14:50:36.784710 IP 192.168.1.123.9090 > 10.0.0.1.47918: Flags [P.], seq 1:134, ack 84, win 227, options [nop,nop,TS val 2323314822 ecr 101915210], length 133
14:50:36.820339 IP 10.0.0.1.47918 > 192.168.1.123.9090: Flags [.], ack 134, win 224, options [nop,nop,TS val 101915401 ecr 2323314822], length 0
14:50:36.820383 IP 10.0.0.1.47918 > 192.168.1.123.9090: Flags [F.], seq 84, ack 134, win 224, options [nop,nop,TS val 101915401 ecr 2323314822], length 0
14:50:36.977226 IP 192.168.1.123.9090 > 10.0.0.1.47918: Flags [F.], seq 134, ack 85, win 227, options [nop,nop,TS val 2323314870 ecr 101915401], length 0
14:50:37.011927 IP 10.0.0.1.47918 > 192.168.1.123.9090: Flags [.], ack 135, win 224, options [nop,nop,TS val 101915594 ecr 2323314870], length 0

3. WireGuard的用户层实现

在linux上,我们务必使用WireGuard的内核模式,这显然是最高效的。在macOS、Windows上,WireGuard无法以内核模块驻留模式运行,但WireGuard项目提供了WireGuard的用户层实现。其作者Jason A. Donenfeld亲自实现了Go语言版本的wireguard-go。macOS上使用的就是wireguard的Go实现。我们可以使用brew在macOS上按照WireGuard:

$brew install wireguard-tools

配置好/etc/wireguard/wg0.conf后(和linux上的配置方式一致),同样可以通过wg-quick命令启动wireguard:

$sudo wg-quick up wg0

wg-quick实际上会通过wireguard-go来实现linux wireguard在内核中完成的功能:

$ps -ef|grep wireguard

    0 57783     1   0  3:18下午 ttys002    0:00.01 wireguard-go utun

三. WireGuard性能如何

关于WireGuard性能如何,官方给出了一个性能基准测试的对比数据(相较于其他vpn网络栈):

img{512x368}

图:WireGuard性能与其他vpn网络栈的对比(来自官方截图)

我们看到和IPSec、OpenVPN相比,无论从吞吐还是延迟,WireGuard都领先不少。

我们这里用microsoft开源的带宽测试工具ethr来直观看一下走物理网络和走WireGuard VPN的带宽差别。

在peer2上运行:

$ ethr -s

然后在peer1上分别通过物理网络和VPN网络向peer2发起请求:

  • peer1 -> peer2 (物理网络)
$ ethr -c  peer2's ip
Connecting to host [peer2 ip], port 9999
[  6] local 172.21.0.5 port 46108 connected to  peer2 ip port 9999
- - - - - - - - - - - - - - - - - - - - - - -
[ ID]   Protocol    Interval      Bits/s
[  6]     TCP      000-001 sec     1.54M
[  6]     TCP      001-002 sec     1.54M
[  6]     TCP      002-003 sec     1.54M
[  6]     TCP      003-004 sec     1.54M
[  6]     TCP      004-005 sec     1.54M

.... ...

  • peer1 -> peer2 (vpn网络)
$ ethr -c 10.0.0.2
Connecting to host [10.0.0.2], port 9999
[  6] local 10.0.0.1 port 36010 connected to 10.0.0.2 port 9999
- - - - - - - - - - - - - - - - - - - - - - -
[ ID]   Protocol    Interval      Bits/s
[  6]     TCP      000-001 sec     1.79M
[  6]     TCP      001-002 sec      640K
[  6]     TCP      002-003 sec     1.15M
[  6]     TCP      003-004 sec      512K
[  6]     TCP      004-005 sec     1.02M
[  6]     TCP      005-006 sec     1.02M
[  6]     TCP      006-007 sec     1.02M

我们看到走vpn的带宽相当于走物理网络的66%(1.02/1.54)左右。这里peer1(腾讯云)、peer2(百度云)之间走的是互联网,而在局域网测试的效果可能更好(留给大家^_^)。

四. 小结

经过上面的实验,我们看到了WireGuard的配置的确十分简单,这也是我目前使用过的配置过程最为简单的vpn。随着linux kernel 5.6内置对WireGuard的原生支持,WireGuard在vpn领域势必会有更为广泛的应用。

在容器网络方面,目前WireGuard已经给出了跨容器的网络通信方案,基于wireguard的k8s cni网络插件wormhole可以让pod之间通过wireguard实现的overlay网络通信。

国外的tailscale公司正在实现一种基于Wireguard的mesh vpn网络,该网络以WireGuard为数据平面的承载体,该公司主要实现控制平面。该公司目前聚集了一些Go核心开发人员,这里就包括著名的go核心开发团队成员、net/http包的最初作者和当前维护者的Brad Fitzpatrick。

五. 参考资料


我的网课“Kubernetes实战:高可用集群搭建、配置、运维与应用”在慕课网上线了,感谢小伙伴们学习支持!

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/
smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

微博:https://weibo.com/bigwhite20xx
微信公众号:iamtonybai
博客:tonybai.com
github: https://github.com/bigwhite

微信赞赏:
img{512x368}

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

图解Go运行时调度器

本文翻译自《Illustrated Tales of Go Runtime Scheduler》

译注:原文章结构有些乱,笔者自行在译文中增加了一些分级标题,让结构显得更清晰一些:)。

goroutines形式的Go并发是编写现代并发软件的一种非常方便的方法,但是您的Go程序是如何高效地运行这些goroutines的呢?

在这篇文章中,我们将深入Go运行时底层,从设计角度了解Go运行时调度程序是如何实现其魔法的,并运用这些原理去解释在Go性能调试过程中产生的Go调度程序跟踪信息

所有的工程奇迹都源于需要。因此,要了解为什么需要一个Go运行时调度程序以及它是如何工作的,我们可以让时间回到操作系统兴起的那个时代,回顾操作系统的历史可以使我们深入的了解问题的根源。如果不了解问题的根源,就没有解决它的希望。这就是历史所能做的。

一. 操作系统的历史

  1. 单用户(无操作系统)。
  2. 批处理,独占系统,直到运行完成。
  3. 多道程序(译注:允许多个程序同时进入内存并运行)

多道程序的目的是使CPU和I/O重叠(overlap)。(译注:多道程序出现之前,当操作系统执行I/O操作时,CPU是空闲的;多道程序的引入实现了在一个程序占用CPU的时候,另一个程序在执行I/O操作)

那怎么实现多道程序(的CPU与I/O重叠)呢?两种方式:多道批处理系统和分时系统。

  • 多道批处理系统

    • IBM OS/MFT(具有固定数量的任务的多道程序)
    • IBM OS/MVT(具有可变数量的任务的多道程序)在这里,每个作业(job)仅获得其所需的内存量。随着job的进出,内存的划分会发生变化。
  • 分时

    • 这是一种多道程序设计,可以在作业之间快速切换。决定何时切换以及切换到哪个作业的过程就称为调度(scheduling)

当前,大多数操作系统使用分时调度程序

那么这些调度程序将用来调度什么实体(entity)呢?

  • 不同的正在执行的程序(即进程process)
  • 或作为进程子集存在使用CPU的基本单元:线程

但是在这些实体的切换是有代价的。

  • 调度成本

img{512x368}

图: 进程和线程的状态变量

因此,使用一个包含多个线程的进程的效率更高,因为进程创建既耗时又耗费资源。但是随后出现了多线程问题:C10k成为主要问题。

例如,如果将调度周期定为10ms(毫秒),并且有2个线程,则每个线程将分别获得5ms。如果您有5个线程,则每个线程将获得2ms。但是,如果有1000个线程怎么办?给每个线程一个10μs(微秒)的时间片?错,这样做很愚蠢,因为您将花费大量时间进行上下文切换,但是真正要完成的工作却进展缓慢或停滞不前。

您需要限制时间片的长度。在最后一种情况下,如果最小时间片为2ms并且有1000个线程,则调度周期需要增加到2s(10002ms)。如果有10,000个线程,则调度程序周期为20秒(100002ms)。在这个简单的示例中,如果每个线程都将分配给它的时间片用完,那么所有线程都完成一次运行需要20秒。因此,我们需要一些可以使并发成本降低而又不会造成过多开销的东西。

  • 用户层线程
    • 线程完全由运行时系统(用户级库)管理。
    • 理想情况下,快速高效:切换线程的代价不比函数调用多多少。
    • 操作系统内核对用户层线程一无所知,并像对待单线程进程(single-threaded process)一样对其进行管理。

在Go中,我们知道这样的用户层线程被称为“Goroutine”。

  • Goroutine

img{512x368}

图: goroutine vs. 线程

goroutine是由Go运行时管理的轻量级线程(lightweight thread)。要启动一个新的goroutine,只需在函数前面使用go关键字:go add(a, b)

  • Goroutine之旅
func main() {
    var wg sync.WaitGroup
    for i := 0; i <= 10; i++ {
        wg.Add(1)
        go func(i int) {
        defer wg.Done()
        fmt.Printf("loop i is - %d\n", i)
        }(i)
    }
    wg.Wait()
    fmt.Println("Hello, Welcome to Go")
}

https://play.golang.org/p/73lESLiva0A

您能猜出上面代码片段的输出吗?

loop i is - 10
loop i is - 0
loop i is - 1
loop i is - 2
loop i is - 3
loop i is - 4
loop i is - 5
loop i is - 6
loop i is - 7
loop i is - 8
loop i is - 9
Hello, Welcome to Go

如果我们看一下输出的一种组合,你可能马上就会有两个问题:

  • 11个goroutine如何并行运行?魔法?
  • goroutine以什么顺序运行?

img{512x368}

图:gopher版奇异博士

上面的这两个提问给我们带来了问题。

  • 问题概述
    • 如何将这些goroutines分配到在CPU处理器上运行的多个操作系统线程上运行?
    • 这些goroutines应该以什么顺序运行才能保证公平?

本文后续的讨论将主要围绕Go运行时调度程序从设计角度如何解决这些问题。但是,与所有问题一样,我们的讨论也需要定义一个明确的边界。否则,问题陈述可能太含糊,无法形成结论。调度程序可能针对多个目标中的一个或多个,对于我们来说,我们将自己限制在以下需求之内:

  1. 应该是并行、可扩展且公平的。
  2. 每个进程应可扩展到数百万个goroutine(C10M
  3. 内存利用率高。(RAM很便宜,但不是免费的。)
  4. 系统调用不应导致性能下降。(最大化吞吐量,最小化等待时间)

让我们开始为调度程序建模,以逐步解决这些问题。

二. Goroutine调度程序模型 (译者自行加的标题)

1. 模型概述(译者自行加的标题)

a) 一个线程执行一个Goroutine

局限性:

  • 并行和可扩展
    • 并行(是的)
    • 可扩展(不是真的)
  • 每个进程不能扩展到数百万个goroutine(C10M)。

b) M:N线程—混合线程

M个操作系统内核线程执行N个“goroutine”

img{512x368}

图: M个内核线程执行N个goroutine

实际执行代码和并行执行都需要内核线程。但是线程创建起来很昂贵,因此我们将N个goroutines映射到M个内核线程上去执行。Goroutine是Go代码,因此我们可以完全控制它。而且它在用户空间中,创建起来很便宜。

但是由于操作系统对goroutine一无所知。因此每个goroutine都有一个状态,以帮助调度器根据goroutine状态知道要运行哪个goroutine。与内核线程的状态信息相比,goroutine的状态信息很小,因此goroutine的上下文切换变得非常快。

  • 正在运行(Running) – 当前在内核线程上运行的goroutine。
  • 可运行(Runnable) – 等待内核线程来运行的goroutine。
  • 已阻塞(Blocked) – 等待某些条件的Goroutine(例如,阻塞在channel操作,系统调用,互斥锁上的goroutine)

img{512x368}

图: 2个线程同时运行2个goroutine

因此,Go运行时调度器通过将N个Goroutine多路复用到M个内核线程的方式来管理处于各种不同状态的goroutines。

2. 简单的M:N调度器

在我们简单的M:N调度器中,我们有一个全局运行队列(global run queue),某些操作将一个新的goroutine放入运行队列。M个内核线程访问调度程序从“运行队列”中获取并运行goroutine。多个线程正在尝试访问相同的内存区域,因此使用互斥锁来同步对该运行队列的访问。

img{512x368}

图: 简单的M:N调度器

但是,那些已阻塞的goroutine在哪里?

下面是goroutine可能会阻塞的情况:

  1. 在channel上发送和接收
  2. 网络I/O操作
  3. 阻塞的系统调用
  4. 使用定时器
  5. 使用互斥锁

那么我们将这些阻塞的goroutine放在哪里呢?— 将这些阻塞的goroutine放置在哪里的设计决策基本上是围绕一个基本原理进行的:

阻塞的goroutine不应阻塞底层内核线程!(避免线程上下文切换的成本)

channel操作期间阻塞的Goroutine

每个channel都有一个recvq(waitq),用于存储试图从该channel读取数据而阻塞的goroutine。

Sendq(waitq)存储试图将数据发送到channel而被阻止的goroutine 。(channel实现原理:-https://codeburst.io/diving-deep-into-the-golang-channels-549fd4ed21a8)

img{512x368}

图: channel操作期间阻塞的Goroutine

channel本身会将channel操作后的未阻塞goroutine放入“运行”队列(run queue)。

img{512x368}

图: channel操作后未阻碍的goroutine

那系统调用呢?

首先,让我们看一下阻塞系统调用。系统调用会阻塞底层内核线程,因此我们无法在该线程上调度任何其他Goroutine。

隐含阻塞系统调用可降低并行度。

img{512x368}

图: 阻塞系统调用可降低并行度

一旦发生阻塞系统调用,我们无法再在M2线程上安排任何其他Goroutine运行,从而导致CPU浪费。由于我们有工作要做,但没法运行它。

恢复并行度的方法是在进入系统调用时,我们可以唤醒另一个线程,该线程将从运行队列中选择可运行的goroutine。

img{512x368}

图: 恢复并行度的方法

但是现在,系统调用完成后,我们有超额等待调度的goroutine。因此,我们不会立即运行从阻塞系统调用中返回的goroutine。我们会将其放入调度程序的运行队列中。

img{512x368}

图: 避免超额等待调度

因此,在程序运行时,线程数远大于cpu核数。尽管没有明确说明,线程数大于cpu核数,并且所有空闲线程也由运行时管理,以避免启动过多的线程。

https://golang.org/pkg/runtime/debug/#SetMaxThreads

初始设置为10,000个线程,如果超过10,000个线程,程序将崩溃。

非阻塞系统调用-将goroutine阻塞在Integrated runtime poller上 ,并释放线程以运行另一个goroutine。

img{512x368}

例如,在非阻塞I/O(例如HTTP调用)的情况下。由于资源尚未准备就绪,第一个syscall将不会成功,这将迫使Go使用network poller并将goroutine暂停。

部分net.Read函数的实现:

    n, err := syscall.Read(fd.Sysfd, p)
        if err != nil {
            n = 0
            if err == syscall.EAGAIN && fd.pd.pollable() {
                if err = fd.pd.waitRead(fd.isFile); err == nil {
                    continue
                }
            }
    }

一旦完成第一个系统调用并明确指出资源尚未准备就绪,goroutine将暂停,直到network poller通知它资源已准备就绪。在这种情况下,线程M将不会被阻塞。

Poller将基于操作系统使用select/kqueue/epoll/IOCP等机制来知道哪个文件描述符已准备好,一旦文件描述符准备好进行读取或写入,它将把goroutine放回到运行队列中。

还有一个Sysmon OS线程,如果超过10ms未轮询网络,它就将定期轮询网络,并将已就绪的G添加到队列中。

基本上所有goroutine都被阻塞在下面操作上:

  1. channel
  2. 互斥锁
  3. 网络IO
  4. 定时器

有某种队列,可以帮助调度这些goroutine。

现在,运行时拥有具有以下功能的调度程序。

  • 它可以处理并行执行(多线程)。
  • 处理阻塞系统调用和网络I/O。
  • 处理阻塞在用户级别(在channel上)的调用。

但这不是可伸缩的(scalable)。

img{512x368}

图: 使用Mutex同步全局运行队列

您可以通过Mutex同步全局运行队列,但最终会遇到一些问题,例如

  1. 缓存一致性保证的开销。
  2. 在创建,销毁和调度Goroutine G时进行激烈的锁竞争。

使用分布式调度程序解决可伸缩性问题。

分布式调度程序-每个线程一个运行队列

img{512x368}

图: 分布式运行队列的调度程序

这样,我们可以看到的直接好处是,每个线程的本地运行队列(local run queue)现在都没有使用mutex。仍然有一个带有mutex的全局运行队列,但仅在特殊情况下使用。它不会影响可伸缩性。

但是现在,我们有多个运行队列。

  1. 本地运行队列
  2. 全局运行队列
  3. 网络轮询器(network poller)

我们应该从哪里运行下一个goroutine?

在Go中,轮询顺序定义如下:
1. 本地运行队列
2. 全局运行队列
3. 网络轮询器
4. 工作偷窃(work stealing)

即首先检查本地运行队列,如果为空则检查全局运行队列,然后检查网络轮询器,最后进行“偷窃工作”。到目前为止,我们对1,2,3有了一些概述。让我们看一下“工作偷窃(work stealing)”。

工作偷窃

如果本地工作队列为空,请尝试“从其他队列中偷窃工作”

img{512x368}

图: 偷窃工作

当一个线程有太多工作要做而另一个线程空闲时,工作偷窃可以解决这个问题。在Go中,如果本地队列为空,工作偷窃将尝试满足以下条件之一。

  • 从全局队列中拉取工作。
  • 从网络轮询器中拉取工作
  • 从其他线程的本地队列中偷窃工作

到目前为止,Go运行时的调度器具有以下功能:

  • 它可以处理并行执行(使用多线程)。
  • 处理阻塞系统调用和网络I/O。
  • 处理用户级别(比如:在channel)的阻塞调用。
  • 可伸缩扩展(scalable)

但这仍不是最有效的。

还记得我们在阻塞系统调用中恢复并行度的方式吗?

img{512x368}

图: 系统调用操作

它暗示在一个系统调用中我们可以有多个内核线程(可以是10或1000),这可能会比cpu核数多很多。这个方案将最终在以下期间产生了恒定的开销:

  • 偷窃工作时,它必须同时扫描所有内核线程(空闲的和运行goroutine的)本地运行队列,并且大多数都将是空闲的。
  • 垃圾回收,内存分配器都会遇到相同的扫描问题。(https://blog.learngoprogramming.com/a-visual-guide-to-golang-memory-allocator-from-ground-up-e132258453ed)

使用M:P:N线程克服效率问题。

M:P:N(3级调度程序)— 引入逻辑处理器P

P —表示处理器,可以将其视为在线程上运行的本地调度程序

img{512x368}

图: M:P:N模型

逻辑进程P的数量始终是固定的。(默认为当前进程可以使用的逻辑CPU数量)

然后,我们将本地运行队列(LRQ)放入固定数量的逻辑处理器(P)中(译者注:而不是每个内核线程一个本地运行队列)。

img{512x368}

图: 分布式三级运行队列调度程序

Go运行时将首先根据计算机的逻辑CPU数量(或根据请求)创建固定数量的逻辑处理器P。

每个goroutine(G)将在分配了逻辑CPU(P)的OS线程(M)上运行。

所以现在我们在以下期间没有了恒定的开销:

  • 偷窃工作 -只需扫描固定数量的逻辑处理器(P)的本地运行队列。
  • 垃圾回收,内存分配器也将获得相同的好处。

使用固定逻辑处理器(P)的系统调用呢?

Go通过将它们包装在运行时中来优化系统调用(无论是否阻塞)。

img{512x368}

图: 阻塞系统调用的包装器

阻塞SYSCALL方法封装在runtime.entersyscall(SB)和 runtime.exitsyscall(SB)之间。

从字面上看,某些逻辑在进入系统调用之前被执行,而某些逻辑在系统调用返回之后执行。进行阻塞的系统调用时,此包装器将自动将P与线程M(即将执行阻塞系统调用的线程)解绑,并允许另一个线程在其上运行。

img{512x368}

图:阻塞Syscall的M交出P

这使得Go运行时可以高效地处理阻塞的系统调用,而无需增加运行队列(译注:本地运行队列数量始终是和P数量一致的)。

一旦阻塞系统调用返回,会发生什么?

  • 运行时会尝试获取之前绑定的那个P,然后继续执行。
  • 运行时尝试在P空闲列表中获取一个P并恢复执行。
  • 运行时将goroutine放在全局队列中,并将关联的M放回M空闲列表。

自旋线程和空闲线程

当M2线程在syscall返回后变得空闲时。如何处理这个空闲的M2线程。从理论上讲,如果线程完成了所需的操作,则应将其销毁,然后再安排进程中的其他线程到CPU上执行。这就是我们通常所说的操作系统中线程的“抢占式调度”。

考虑上述syscall中的情况。如果我们销毁了M2线程,而同时M3线程即将进入syscall。此时,在OS创建新的内核线程并将其调度执行之前,我们无法处理可运行的goroutine。频繁的线程前抢占操作不仅会增加OS的负载,而且对于性能要求更高的程序几乎是不可接受的。

因此,为了适当地利用操作系统的资源并防止频繁的线程抢占给操作系统带来的负担,我们不会销毁内核线程M2,而是使其执行自旋操作并以备将来使用。尽管这看起来是在浪费一些资源。但是,与线程之间的频繁抢占以及频繁的创建和销毁操作相比,“空闲线程”要付出的代价更少。

Spinning Thread(自旋线程) — 例如,在具有一个内核线程M(1)和一个逻辑处理器(P)的Go程序中,如果正在执行的M被syscall阻塞,则运行时会请求与P数量相同的“Spinning Threads”以允许等待的可运行goroutine继续执行。因此,在此期间,内核线程的数量M将大于P的数量(自旋线程+阻塞线程)。因此,即使将runtime.GOMAXPROCS的值设置为1,程序也将处于多线程状态。

调度中的公平性如何?—公平地选择下一个要执行的goroutine

与许多其他调度程序一样,Go也具有公平性约束,并且由goroutine的实现所强加,因为Runnable goroutine应该最终得到调度并运行。

这是Go Runtime Scheduler的四个典型的公平性约束:

任何运行时间超过10ms的goroutine都被标记为可抢占(软限制)。但是,抢占仅在函数执行开始处才能完成。Go当前在函数开始处中使用了由编译器插入的协作抢占点。

  • 无限循环 – 抢占(约10毫秒的时间片)- 软限制

但请小心无限循环,因为Go的调度程序不是抢先的(直到Go 1.13)。如果循环不包含任何抢占点(例如函数调用或分配内存),则它们将阻止其他goroutine的运行。一个简单的例子是:

package main

func main() {
    go println("goroutine ran")
    for {}
}

如果你运行:

GOMAXPROCS=1 go run main.go

直到Go(1.13)才可能打印该语句。由于缺少抢占点,main Goroutine将独占处理器。

  • 本地运行队列 -抢占(〜10ms时间片)- 软限制
  • 通过每61次调度就检查一次全局运行队列,可以避免全局运行队列处于“饥饿”状态。
  • 网络轮询器饥饿 后台线程会在主工作线程未轮询的情况下偶尔会轮询网络。

Go 1.14有一个新的“非合作抢占”机制。

有了这种机制,Go运行时便有了具有所有必需功能的Scheduler。

  • 它可以处理并行执行(多线程)。
  • 处理阻塞系统调用和网络I/O。
  • 处理用户级别(在channel上)的阻塞调用。
  • 可扩展
  • 高效
  • 公平

这提供了大量的并发性,并且始终尝试实现最大的利用率和最小的延迟。

现在,我们总体上对Go运行时调度程序有了一些了解,我们如何使用它?Go为我们提供了一个跟踪工具,即调度程序跟踪(scheduler trace),目的是提供有关调度行为的信息并用来调试与goroutine调度器伸缩性相关的问题。

三. 调度器跟踪

使用GODEBUG=schedtrace=DURATION环境变量运行Go程序以启用调度程序跟踪。(DURATION是以毫秒为单位的输出周期。)

img{512x368}

图:以100ms粒度对schedtrace输出采样

有关调度器跟踪的内容,Go Wiki拥有更多信息。

参考:Dmitry Vyukov的可扩展Go Scheduler设计文档和演讲 https://docs.google.com/document/d/1TTj4T2JO42uD5ID9e89oa0sLKhJYD0Y_kqxDv3I3XMw/edit

Gopher艺术作品致谢:Ashley Mcnamara。


我的网课“Kubernetes实战:高可用集群搭建、配置、运维与应用”在慕课网上线了,感谢小伙伴们学习支持!

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/
smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

微博:https://weibo.com/bigwhite20xx
微信公众号:iamtonybai
博客:tonybai.com
github: https://github.com/bigwhite

微信赞赏:
img{512x368}

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats