聊聊go语言基于epoll的网络并发实现
# 写在文章开头
在之前的文章中我们已经介绍了epoll模型,而本文就从go语言源码的角度来了解一下,go语言是如何基于epoll模型完成高性能的网络协程并发程序的。

Hi,我是 sharkChili ,是个不断在硬核技术上作死的 java coder ,是 CSDN的博客专家 ,也是开源项目 Java Guide 的维护者之一,熟悉 Java 也会一点 Go ,偶尔也会在 C源码 边缘徘徊。写过很多有意思的技术博客,也还在研究并输出技术的路上,希望我的文章对你有帮助,非常欢迎你关注我的公众号: 写代码的SharkChili 。
因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。

# 详解go语言对于epoll的抽象
# 从设计角度了解go语言的网络协程工作机制
当客户端和服务端建立连接之后,每一个协程go-routine都会得到对应的establish socket,而go语言则会将对应socket的文件描述符fd注册到网络轮询器上,如果epoll轮询到对应的socket的事件,则唤醒对应的协程并处理这些读写事件,反之则将协程挂起:

# 从顶层设计入手
我们可以从netpoll.go这个文件中看到go语言对于多路复用器的抽象,从注释可以看出任何操作系统的网络轮询器都需要实现如下几个方法:
netpollinit:新建网络轮询器。netpollopen:通过边缘触发的方式将这些建立连接的socket的fd注册到网络轮询器中。netpoll:获取就绪的socket的读写事件。
这里我们也贴出这段代码的注释:
// func netpollinit()
// Initialize the poller. Only called once.
//
// func netpollopen(fd uintptr, pd *pollDesc) int32
// Arm edge-triggered notifications for fd. The pd argument is to pass
// back to netpollready when fd is ready. Return an errno value.
//
// func netpollclose(fd uintptr) int32
// Disable notifications for fd. Return an errno value.
//
// func netpoll(delta int64) gList
// Poll the network. If delta < 0, block indefinitely. If delta == 0,
// poll without blocking. If delta > 0, block for up to delta nanoseconds.
// Return a list of goroutines built by calling netpollready.
//
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 新建网络轮询器
我们以Linux为例,查看对应的实现类netpoll_epoll.go如何完成网络轮询器的抽象,可以看到Linux系统下的netpollinit方法,本质就是调用C语言的epoll_create方法并创建网络轮询器,并将我们服务端socket的连接输入事件注册到epoll模型上:
func netpollinit() {
var errno uintptr
//调用底层C语言实现的epoll_create创建网络轮询器
epfd, errno = syscall.EpollCreate1(syscall.EPOLL_CLOEXEC)
//......
//封装输入事件ev
ev := syscall.EpollEvent{
Events: syscall.EPOLLIN,
}
//......
//将输入事件注册到epoll
errno = syscall.EpollCtl(epfd, syscall.EPOLL_CTL_ADD, r, &ev)
//......
}
2
3
4
5
6
7
8
9
10
11
12
13
14
# 插入事件
每当客户端和服务端socket建立连接之后,服务端都会为当前客户端创建一个establish socket对应的socket的文件描述符就会被封装成pollDesc,并将其对应的输入、输出等事件通过注册到epoll上,并将epoll设置为边缘触发模式,等待epoll轮询通知当前socket对应的协程处理。
ps:这里简单介绍一下边缘触发,和水平触发不同,水平触发只要一有网络IO数据就通知socket处理,而边缘触发为避免这种频繁在用户态到内核态的开销,如果当前IO数据没处理完,则等待下一次IO数据就绪后再处理,所以这种模式就要求应用程序必须一次性将数据读取完成,在应用层面进行处理。

对应的我们也给出这段描述的代码实现:
func netpollopen(fd uintptr, pd *pollDesc) uintptr {
//封装输入、输出、挂起注册到epoll,并将epoll设置为边缘触发模式运行
var ev syscall.EpollEvent
ev.Events = syscall.EPOLLIN | syscall.EPOLLOUT | syscall.EPOLLRDHUP | syscall.EPOLLET
*(**pollDesc)(unsafe.Pointer(&ev.Data)) = pd
//调用C语言的epoll_create为当前establish socket注册事件到epoll中
return syscall.EpollCtl(epfd, syscall.EPOLL_CTL_ADD, int32(fd), &ev)
}
2
3
4
5
6
7
8
# 查询事件
Linux系统对于事件轮询的方法netpoll的实现则比较简单,调用C语言的epoll_wait获取就绪的事件,基于这些事件定位到对应的socket,并将socket对应的协程存入待运行列表toRun等待被轮询处理:

对应的我们也给出Linux的实现netpoll_epoll.go关于netpoll的源码实现:
func netpoll(delay int64) gList {
//......
var events [128]syscall.EpollEvent
retry:
//调用epoll_wait查看注册的socket事件中是否有就绪的事件
n, errno := syscall.EpollWait(epfd, events[:], int32(len(events)), waitms)
//......
var toRun gList
//遍历事件列表
for i := int32(0); i < n; i++ {
ev := events[i]
if ev.Events == 0 {
continue
}
//......
//判断消息的读写类型
var mode int32
if ev.Events&(syscall.EPOLLIN|syscall.EPOLLRDHUP|syscall.EPOLLHUP|syscall.EPOLLERR) != 0 {
mode += 'r'
}
if ev.Events&(syscall.EPOLLOUT|syscall.EPOLLHUP|syscall.EPOLLERR) != 0 {
mode += 'w'
}
//将这些事件对应socket的协程存入toRun这个协程列表中,等待被唤醒并处理
if mode != 0 {
pd := *(**pollDesc)(unsafe.Pointer(&ev.Data))
pd.setEventErr(ev.Events == syscall.EPOLLERR)
netpollready(&toRun, pd, mode)
}
}
return toRun
}
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
# 小结
以上便是笔者对于go语言中在Linux下对于网络轮询器抽象的剖析,希望对你有帮助,感谢您的阅读!
我是 sharkchili ,CSDN Java 领域博客专家,开源项目—JavaGuide contributor,我想写一些有意思的东西,希望对你有帮助,如果你想实时收到我写的硬核的文章也欢迎你关注我的公众号: 写代码的SharkChili 。 因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。

# 参考
epoll:水平触发与边缘触发 :https://zhuanlan.zhihu.com/p/363353777 (opens new window)