【导读】本文是一篇 go 语言网络编程中 tcp socket 项目实践。
前言
前几年刚学 golang 时听过这么个论调:golang 要制霸云计算行业。具体是不是这样笔者就不知道了。不过这也体现出 golang 在网络编程这一块的实力可见一斑。今天我们就探讨下网络编程中的 socket 编程的那些事儿。
socket 入门
概念
socket,中文是套接字的意思。套接字又是干什么的?是负责进程间通信的。进程间通信有很多种,socket 在进程间通信中起到了什么样的作用呢?起到了跨越千山万水和某一台主机的进程通信,也就是网络中两个节点进行通信。那 A 机器的进程怎么和 B 机器的进程进行通信?套接字是这么设计的:找到 B 机器的 ip 和端口就行。很简洁明了,对方的 ip 锁定了,就是锁定对方主机;如果端口号再锁定,进程也锁定。所以,套接字能进程间通信也就顺理成章了。
实现
既然套接字=ip+port,那么怎么实现一个套接字呢?这里笔者加一点对架构的探讨,如果是你,你怎么实现这个套接字?换句话说就是要暴露(这里指的是操作系统去暴露)哪些接口供应用程序调用?
-
首先得能创建套接字,也就是得有个 create 的接口; -
其次还得能绑定 ip 和端口号,也就是 bind,不然别人找不到你; -
有了这些还得有点东西,那就是别人怎么连接你。也就是要有个 connect 和 accept 的功能。 -
再者还得实现读和写,也就是 read 和 write,因为我们要通过套接字通信,不能读不能写肯定不行; -
通信完毕后套接字要关闭。
那顺着这个思路,我们来看看操作系统都是怎么暴露 socket 接口的,先看看服务端的接口
创建套接字,用的名字不是 create 而是直接用的 socket,看下代码(c 语言格式)
int socket(int domain, int type, int protocol)
domain 指的是 ip 地址的类型,比如是 ipv4、ipv6 或者本地套接字;type 就是指的是数据格式,准确地讲就是是 tcp 还是 udp 等;protocol 这个字段好像废弃了,默认是 0。
绑定用的名字就是 bind,看下面代码(c 语言格式):
bind(int fd, sockaddr * addr, socklen_t len)
第一个参数 fd 就是 socket 函数的返回值,可以理解为就是一个文件描述符,linux 一切皆文件嘛。第二个是 sockaddr 类型的指针,这个是一种通用的地质类型,就是因为通用了,所以才有第三个参数。第三个参数是第二个参数的解析标准。因为第二个参数是通用格式,只能根据长度字段来判断第二个参数该如何从通用解析到具体。这里要注意的是,客户端套接字可以不需要(最好也别)手动调用 bind,因为操作系统会分配端口号的,因为防止端口复用。
下面再来看看一个监听的操作,也就是 listen。这个 listen 操作指的是服务端创建套接字要调用的,相当于通知外界我已经在这个端口和 ip 工作了,赶紧来撩我吧。这里要注意的是,listen 这个函数是发生在 bind 之后。看下面声明(C 语言格式)
int listen (int socketfd, int backlog)
第一个参数,socketfd 是套接字描述符,也就是 socket 函数的返回值,和 bind 函数的第一个参数是一样一样的。第二个参数 backlog 是未完成连接队列的大小,这个参数决定了这个套接字能处理多少并发,原则上越大处理并发越大,但是并发多了消耗资源也挺多,这个就需要一定策略了。其实 linux 系统中这个参数默认不允许修改。
现在服务器端的从创建到绑定再到监听,一系列的准备都搞定,客户端可以开始撩了。一旦客户端和服务器端撩上了,服务器端的操作系统内核就要通知应用程序有人来撩你了,那服务器端就要为客户端服务了。这个连接建立的过程就是 accept,来看看代码:
int accept(int listensockfd, struct sockaddr *cliaddr, socklen_t *addrlen)
第一个参数 listensocketfd 指的是 listen 函数的返回值,也就是监听套接字。第二、第三个参数其实不是参数,是返回值。因为 C 语言不支持多返回值(这里要感谢 golang 支持),不得已才这样玩。cliaddr 和 addrlen 是客户端的地址结构和长度。当然,accept 函数还有个函数返回值,是个 int 类型,这个返回值很有意思,其实也是个套接字描述符,可以理解为 listensockfd 的副本。为啥叫副本呢,因为客户端和服务端今后通信的实际套接字就是这个副本。为啥不是 listensockfd 本身呢?很简单,如果 listensockfd 和客户端关闭连接了,那么服务端也不能继续提供服务了,也就是服务端只服务了一个客户,那是万万不行的。
客户端新建连接的时候也需要创建套接字,和上面的 socket 函数是一样的。当客户端新建了 socket 后,是需要和服务器端连接的,这个连接的建立就是靠 connect 函数完成的:
int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen)
sockfd 是套接字建立后返回值,servaddr 和 addrlen 是指向套接字地址结构的指针和该结构的大小。套接字地址结构必须有服务器的 ip 和端口号。这里要注意几点:
-
客户端并不需要 bind,因为没必要,操作系统会按照一定算法生成端口的。 -
当客户端调用 connect 之后会阻塞,服务器端调用 accept 之后也会阻塞。剩下的就是操作系统来完成连接的建立(比如 TCP 的三次握手);
连接建立之后,客户端和服务器就可以信息交互,也就涉及到了套接字的读写。先来看看写:
ssize_t write (int socketfd, const void *buffer, size_t size)
ssize_t send (int socketfd, const void *buffer, size_t size, int flags)
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags)
这三个函数都可以向套接字中写入数据,但是用法不一样 write 是常见的文件写入函数,send 是为了发送一些紧急的数据,TCP 协议特定情况下需要使用的到。向多重缓冲区发送数据,就是 sendmsg 函数。这里要注意,往套接字写数据的时候,写完了并不代表对端收到了。写完了仅仅是写到操作系统缓冲成功了,至于对端收到与否是操作系统和 TCP 协议决定的。并且如果操作系统的缓冲区慢了,写会阻塞,直到全部写入缓冲区。
从套接字中读数据:
ssize_t read (int socketfd, void *buffer, size_t size)
这里的 read 函数是指操作系统从套接字中最多读取多少个字节,并把读取到的数据村道 buffer 中。返回值是实际读了多少字节,如果返回值是-1 表示出错。如果为 0 表示 EOF,也就是可能对端断开连接。
最后我们再来探讨下套接字的关闭。当客户端和服务器完成交互时,就要关闭套接字。一来释放掉文件描述符,二来服务端释放掉端口号。当然还有其他的资源,这里就不一一赘述,看下有哪些可以关闭套接字的操作吧:
int close(int sockfd)
int shutdown(int sockfd, int howto)
很容易想到的就是 close 操作了,类似于对文件操作一样。不过这里的 close 比文件的关闭还不太一样,这有点多态的意思。那我们来看看 close 和 shutdown 有什么区别吧:
-
close 首先是减去套接字的引用计数,当引用计数减到 0 的时候,套接字关闭。这里的引用计数指的是引用套接字的进程,比如我们 fork 的子进程等。这里的关闭是指读写端都关闭。 -
shutdown 函数可以根据 howto 参数决定关闭套接字的哪一端。比如读方向或者写方向或者读写都关了。
golang socket 实践
理解了 socket 的常规接口之后,我们探讨下 golang 中是如何使用 socket;
建立连接
服务器端监听端口:
l, err := net.Listen("tcp", ":9999")
if err != nil {
log.Println(err)
return
}
for {
c, err := l.Accept()
if err != nil {
log.Println(err)
break
}
}
// 客户端建立连接核心代码:
c, err := net.Dial("tcp", ":9999")
if err != nil {
log.Println(err)
return
}
代码可以看出,服务器端操作套接字的接口就是两个函数 Listen 和 Accept,不过要注意的是,这里的 Listen 和 Accept 和前面我们探讨的系统调用中的 listen 和 accept 不是一回事,这里的内部实现最终还是调用上面探讨过的几个系统调用。
客户端建立连接的接口就是一个函数 Dial,其实还有一个带着超时时间的接口:DialTimeout 和一个带有上下文环境的 DialContext。但是不管怎么样,客户端建立连接在 golang 中一个函数就搞定。
当连接建立完成时,客户端和服务端都会得到一个叫 Conn 的实例,这个就是真正去进行读写交互的。但是天有不测风云,连接的建立往往不是一帆风顺。有这么几种情况要注意:
-
服务端没启动,客户端会报错。也就是那个 Dial 函数会返回错误异常。 -
服务端延迟很高,那个客户端的 DialTimeout 函数如果超时了也会返回异常。 -
如果客户端突然连接的量有点多,服务端又很忙。还记得前面讨论的 listen 系统调用中的 backlog 吗?这个会缓冲一下,但是如果这个缓冲也满了,那么客户端会阻塞在 Dial 上。会阻塞多久这个笔者也没个定论,可能跟 server 端的环境设置有关。
读写
服务端与客户端建立连接之后就用各自的 conn 实例去进行读写,进而实现业务逻辑。套接字通信的过程中,笔者遇到过这么几种情况:阻塞、超时、意外关闭、多 goroutine 读写。下面我们看看这几种情况发生的原因。
阻塞
前文我们已经提到过,我们往 socket 中写数据的时候,其实并不是对方接收到了,而是把数据写到了操作系统的缓冲区。就是因为这个缓冲区的原因,才有了诸多异常(当然,没有缓冲区问题更多,至少操作系统帮我们屏蔽了很多细节),阻塞就是其中之一。造成阻塞的原因有这么几个:
-
socket 中无数据的时候去读; -
socket 中数据满的时候去写;
超时
有些读写操作可能要有时间限制,所以就用了 SetReadDeadline 的函数去设置超时时间,当超过这种时间限制时会发生阻塞。看下面代码:
conn.SetReadDeadline(time.Now().Add(time.Microsecond * 10))
conn.SetWriteDeadline(time.Now().Add(time.Microsecond * 10))
经过这两个函数对 conn 进行设置后,读写操作在 10 微秒没响应的话会报超时的异常。
意外关闭
通信的过程中,如果某一方突然关闭,那另一方会有啥反应?在实践的过程中,笔者总结如下:
-
当对端异常关闭,如果己方 socket 中有数据,那么己方是会继续读取;当 socket 中无数据时,己方 Read 函数返回 EOF。 -
当对端异常关闭,己方如果还在 socket 的缓冲中写入,那么本次写入成功,下次写入就报错。 -
当己端关闭,读写均异常错误。
多 goroutine 读写
如果多个 goroutine 对 conn 进行读写,就会有多重读,多重写两种情况,socket 是全双工,所以读写之间互不影响。
多 goroutine 读的时候,其实没什么影响。因为读的话,反正读到了也是不同业务场景下的东西,多重读不会引发安全问题(不会重复读)。但是有一点就是,有可能一个业务包会被两个不同的 goroutine 读取到,比如 goroutine A 读到了业务包的前半部分,goroutine B 读到了业务包的后半部分;这是 runtime 对业务数据的截取导致。
多 goroutine 写的时候,就有问题了。多个 goroutine 写不能每个写一半,必须保证每次写是原子操作,好在 golang 内部实现写的时候加了锁,这个我们后续探讨。所以,我们要一次性将数据写入 socket,不要分布写。
golang socket 源码分析
本文这里分别针对客户端和服务器端的源码进行分析,不过在源码分析前,我们先了解下 I/O 多路复用的 epoll 机制。
I/O 多路复用之 epoll
多路复用是一种为了应对高并发,比如 C10K 问题而提出的一种解决方案。多路复用,复用的是线程不是 I/O。多路复用中支持单进程同时监听多个文件描述符并且阻塞等待,并在某个文件描述符可读或者可写的时候收到通知。
著名的多路复用实现有 select、poll、epoll(linux 环境),select 和 poll 这里就不多赘述了,重点关注 epoll。epoll 就三个系统调用的函数:
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
-
epoll_create 创建一个 epoll 实例; -
epoll_ctl 注册 fd 等待的 I/O 事件到 epoll 实例上; -
epoll_wait 阻塞监听 epoll 实例上所有的 fd 的 I/O 事件,内核会在有 I/O 事件发生的时候把文件描述符列表复制到 events 上,然后 epoll_wait 解除阻塞并返回。
epoll 的工作原理
epoll 的核心靠的就是事件驱动,当某个套接字注册到 epoll 实例上时,会立即和网卡建立关系,也就是为套接字注册一个回调函数:ep_poll_callback,当网卡有数据了,内核调用这个回调函数来把这个套接字(fd)加入到一个叫 rdllist 的双向链表中。epoll_wait 的职责就是检查这个双向链表有无可用套接字,无则阻塞,有责返回该套接字。
非阻塞 I/O
再看源码前还有一个需要了解的就是非阻塞 I/O,也就是调用 I/O 操作的时候我们可以被立即返回,不用等待 I/O 操作完再进行下一步。为什么要了解非阻塞 I/O 呢,因为多路复用要和非阻塞 I/O 搭配才能发挥更大的作用,不然如果是同步 I/O,可能会卡在那个 epoll_wait 上。
图 1,异步非阻塞 I/O 图示
从上图可以看出,用户进程不用阻塞等待数据返回,也不用不停询问内核准备好了没,这样可以充分利用 CPU 干点其他的事。
服务端套接字源码追踪
先看服务端套接字的创建、监听、读写模型代码实现:
package main
import (
"fmt"
"net"
)
func main() {
l, err := net.Listen("tcp", ":9999")
if err != nil {
fmt.Println("listen error: ", err)
return
}
for {
conn, err := l.Accept()
if err != nil {
fmt.Println("accept error: ", err)
break
}
go ConnHandler(conn)
}
}
func ConnHandler(conn net.Conn) {
defer conn.Close()
packet := make([]byte, 1024)
for {
// 没有可读数据阻塞
_, _ = conn.Read(packet)
// 不可写则阻塞
_, _ = conn.Write(packet)
}
}
这是一个典型的 goroutine-per-connection 模式,使用这种模式可以实现同步的逻辑,也就是说 golang 已经为我们屏蔽了底层所有的异步 I/O 以及协程切换等操作,完完全全负责写业务逻辑即可。这主要归功于我们在 goroutine 文中介绍的那个 netpoll,现在终于都串起来了。这个 netpoll 的最终实现就是 epoll。下面,我们先看看代码中第 9 行的 listen 的实现,看看这个 socket 生成到底是怎么一步步最终到了系统调用的 socket 函数的(省略了部分代码,只留了主线):
// net/dial.go
func (lc *ListenConfig) Listen(ctx context.Context, network, address string) (Listener, error) {
...
switch la := la.(type) {
// 因为我们是 TCP,所以进入这个 case
case *TCPAddr:
l, err = sl.listenTCP(ctx, la)
...
}
// 上一步的 sl.listenTCP net/tcpsock_posix.go
func (sl *sysListener) listenTCP(ctx context.Context, laddr *TCPAddr) (*TCPListener, error) {
// 这里又调用了 internetSocket
fd, err := internetSocket(ctx, sl.network, laddr, nil, syscall.SOCK_STREAM, 0, "listen", sl.ListenConfig.Control)
...
}
// 上一步的 internetSocket net/ipsock_posix.go
func internetSocket(ctx context.Context, net string, laddr, raddr sockaddr, sotype, proto int, mode string, ctrlFn func(string, string, syscall.RawConn) error) (fd *netFD, err error) {
...
return socket(ctx, net, family, sotype, proto, ipv6only, laddr, raddr, ctrlFn)
}
// 上一步的 socket net/sock_posix.go
func socket(ctx context.Context, net string, family, sotype, proto int, ipv6only bool, laddr, raddr sockaddr, ctrlFn func(string, string, syscall.RawConn) error) (fd *netFD, err error) {
s, err := sysSocket(family, sotype, proto)
if err != nil {
return nil, err
}
...
}
// 上一步的 sysSocket net/sys_cloexec.go
func sysSocket(family, sotype, proto int) (int, error) {
// See ../syscall/exec_unix.go for description of ForkLock.
syscall.ForkLock.RLock()
s, err := socketFunc(family, sotype, proto)
...
}
// 上一步的 socketFunc net/hook_unix.go
var (
...
socketFunc func(int, int, int) (int, error) = syscall.Socket
...
)
// 上一步的 syscall.Socket syscall/syscall_unix.go
func Socket(domain, typ, proto int) (fd int, err error) {
if domain == AF_INET6 && SocketDisableIPv6 {
return -1, EAFNOSUPPORT
}
fd, err = socket(domain, typ, proto)
return
}
如上述代码的注释所示,我们一步步找到了 socket 的最终系统调用的地方,所以这也正好符合了我们前文所述的 socket() 系统调用的描述。读者可自行沿着主线一步步找到其他的系统调用。比如 bind、listen 如下:
func (fd *netFD) listenStream(laddr sockaddr, backlog int, ctrlFn func(string, string, syscall.RawConn) error) error {
var err error
if err = setDefaultListenerSockopts(fd.pfd.Sysfd); err != nil {
return err
}
var lsa syscall.Sockaddr
if lsa, err = laddr.sockaddr(fd.family); err != nil {
return err
}
if ctrlFn != nil {
c, err := newRawConn(fd)
if err != nil {
return err
}
if err := ctrlFn(fd.ctrlNetwork(), laddr.String(), c); err != nil {
return err
}
}
if err = syscall.Bind(fd.pfd.Sysfd, lsa); err != nil {
return os.NewSyscallError("bind", err)
}
if err = listenFunc(fd.pfd.Sysfd, backlog); err != nil {
return os.NewSyscallError("listen", err)
}
if err = fd.init(); err != nil {
return err
}
lsa, _ = syscall.Getsockname(fd.pfd.Sysfd)
fd.setAddr(fd.addrFunc()(lsa), nil)
return nil
}
上述代码的第 19 行、22 行分别调用了系统调用的 bind 和 listen。我们再来看看这个套接字的读和写的最终源码实现:
// internal/poll/fd_unix.go
func (fd *FD) Read(p []byte) (int, error) {
if err := fd.readLock(); err != nil {
return 0, err
}
defer fd.readUnlock()
if len(p) == 0 {
// If the caller wanted a zero byte read, return immediately
// without trying (but after acquiring the readLock).
// Otherwise syscall.Read returns 0, nil which looks like
// io.EOF.
// TODO(bradfitz): make it wait for readability? (Issue 15735)
return 0, nil
}
if err := fd.pd.prepareRead(fd.isFile); err != nil {
return 0, err
}
if fd.IsStream && len(p) > maxRW {
p = p[:maxRW]
}
for {
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
}
}
// On MacOS we can see EINTR here if the user
// pressed ^Z. See issue #22838.
if runtime.GOOS == "darwin" && err == syscall.EINTR {
continue
}
}
err = fd.eofError(n, err)
return n, err
}
}
// internal/poll/fd_unix.go
func (fd *FD) Write(p []byte) (int, error) {
if err := fd.writeLock(); err != nil {
return 0, err
}
defer fd.writeUnlock()
if err := fd.pd.prepareWrite(fd.isFile); err != nil {
return 0, err
}
var nn int
for {
max := len(p)
if fd.IsStream && max-nn > maxRW {
max = nn + maxRW
}
n, err := syscall.Write(fd.Sysfd, p[nn:max])
if n > 0 {
nn += n
}
if nn == len(p) {
return nn, err
}
if err == syscall.EAGAIN && fd.pd.pollable() {
if err = fd.pd.waitWrite(fd.isFile); err == nil {
continue
}
}
if err != nil {
return nn, err
}
if n == 0 {
return nn, io.ErrUnexpectedEOF
}
}
}
读写的代码不难找,其实就是 conn 实例的读写,当然最终也是调用的系统调用的读和写。我这里列出代码的意义是要注意,conn 的读和写都是锁住的,注意看上述代码的第 3 行和第 44 行,这也充分验证了我们上述的读和写的 goroutine 安全问题。
epoll 的封装分析
golang 的 tcp socket 编程最伟大的地方在于它封装了基于 epoll 多路复用的非阻塞 I/O,让我们用同步的思维写异步的程序,这么做的有点就是业务逻辑不那么分散,看起来流畅。
那 golang 是怎么实现对 epoll 的封装的呢?有这么几个数据结构要了解一下:
// TCPListener is a TCP network listener. Clients should typically
// use variables of type Listener instead of assuming TCP.
type TCPListener struct {
fd *netFD
}
type netFD struct {
pfd poll.FD
// immutable until Close
family int
sotype int
isConnected bool // handshake completed or use of association with peer
net string
laddr Addr
raddr Addr
}
type FD struct {
// Lock sysfd and serialize access to Read and Write methods.
fdmu fdMutex
// System file descriptor. Immutable until Close.
Sysfd int
// I/O poller.
pd pollDesc
// Writev cache.
iovecs *[]syscall.Iovec
// Semaphore signaled when file is closed.
csema uint32
// Non-zero if this file has been set to blocking mode.
isBlocking uint32
// Whether this is a streaming descriptor, as opposed to a
// packet-based descriptor like a UDP socket. Immutable.
IsStream bool
// Whether a zero byte read indicates EOF. This is false for a
// message based socket connection.
ZeroReadIsEOF bool
// Whether this is a file rather than a network socket.
isFile bool
}
type pollDesc struct {
runtimeCtx uintptr
}
首先这个 TCPListener 是负责监听网络情况,比如有没有连接到来等。这里有个重要的数据结构就是 netFD,这是网络描述符,不是前文我们讲的 fd,其中第 24 行的 Sysfd 才是真正意义上的 fd。这个 netFD 真正重要的是 pfd 下的 pd 字段,也就是第 27 行。这个字段是 pollDesc 类型,从名字可以看出,肯定是和多路复用有关,但是 unitptr 仅仅是个指针,所以这时候我们就要猜测是不是在运行时,也就是 runtime 包里呢?之所以这么猜是因为源代码里充斥着 go:linkname 指令。我们就进去来看看 pollDesc 的相关信息:
// runtime/netpoll.go
type pollDesc struct {
link *pollDesc // in pollcache, protected by pollcache.lock
// The lock protects pollOpen, pollSetDeadline, pollUnblock and deadlineimpl operations.
// This fully covers seq, rt and wt variables. fd is constant throughout the PollDesc lifetime.
// pollReset, pollWait, pollWaitCanceled and runtime·netpollready (IO readiness notification)
// proceed w/o taking the lock. So closing, rg, rd, wg and wd are manipulated
// in a lock-free way by all operations.
// NOTE(dvyukov): the following code uses uintptr to store *g (rg/wg),
// that will blow up when GC starts moving objects.
lock mutex // protects the following fields
fd uintptr
closing bool
user uint32 // user settable cookie
rseq uintptr // protects from stale read timers
rg uintptr // pdReady, pdWait, G waiting for read or nil
rt timer // read deadline timer (set if rt.f != nil)
rd int64 // read deadline
wseq uintptr // protects from stale write timers
wg uintptr // pdReady, pdWait, G waiting for write or nil
wt timer // write deadline timer
wd int64 // write deadline
}
这个 pollDesc 在多路复用中起到了什么作用呢?答,golang 中的网络轮询器就是监听这个 pollDesc 的状态来做出相应的响应,就好像 epoll 监听 fd 的可读或者可写一样。
这里的 pollDesc 笔者理解的是有点入口(可能叫多态或者接口)的意思,比如吧,如果是 linux,那么编译器进入的是 epoll 的逻辑;如果是其他的操作系统,那么就是其他操作系统的多路复用逻辑。
那既然是对 linux 下的 epoll 的封装,我们得找到具体的实现,看下面代码:
// runtime/netpoll_epoll.go
func netpollinit() {
epfd = epollcreate1(_EPOLL_CLOEXEC)
if epfd >= 0 {
return
}
epfd = epollcreate(1024)
if epfd >= 0 {
closeonexec(epfd)
return
}
println("runtime: epollcreate failed with", -epfd)
throw("runtime: netpollinit failed")
}
func netpollopen(fd uintptr, pd *pollDesc) int32 {
var ev epollevent
ev.events = _EPOLLIN | _EPOLLOUT | _EPOLLRDHUP | _EPOLLET
*(**pollDesc)(unsafe.Pointer(&ev.data)) = pd
return -epollctl(epfd, _EPOLL_CTL_ADD, int32(fd), &ev)
}
func netpoll(block bool) gList {
if epfd == -1 {
return gList{}
}
waitms := int32(-1)
if !block {
waitms = 0
}
var events [128]epollevent
retry:
n := epollwait(epfd, &events[0], int32(len(events)), waitms)
if n < 0 {
if n != -_EINTR {
println("runtime: epollwait on fd", epfd, "failed with", -n)
throw("runtime: netpoll failed")
}
goto retry
}
var toRun gList
for i := int32(0); i < n; i++ {
ev := &events[i]
if ev.events == 0 {
continue
}
var mode int32
if ev.events&(_EPOLLIN|_EPOLLRDHUP|_EPOLLHUP|_EPOLLERR) != 0 {
mode += 'r'
}
if ev.events&(_EPOLLOUT|_EPOLLHUP|_EPOLLERR) != 0 {
mode += 'w'
}
if mode != 0 {
pd := *(**pollDesc)(unsafe.Pointer(&ev.data))
netpollready(&toRun, pd, mode)
}
}
if block && toRun.empty() {
goto retry
}
return toRun
}
从上面代码的第 7、20、33 行,我们看到了 epoll 三个系统调用的身影。所以到此为止,我们对 golang tcp socket 的实现都找到了最最底层的系统调用。那最后我们总结下:
首先,Listen 和 Accept 都是能创建套接字的,只不过一个是监听套接字,一个是连接套接字。这两个套接字都会被加入到 epoll(linux)中来进行监听;
其次,网络轮询器,也就是 netpoller 对 pollDesc 的监听就是封装了 epoll 对 fd 的监听。为什么要封装呢?因为这是为了 goroutine 的调度,让需要 I/O 调用的让出线程,在 netpoller 上准备好数据;
总结
本文我们探讨了套接字的相关系统调用、epoll 的原理以及 golang 中对这两者的封装。再重复一遍:golang 的 tcp socket 是同步阻塞的,但是其底层实现是异步非阻塞的,并且也支持多路复用。之所以同步阻塞是因为方便开发者写逻辑,之所以底层是异步非阻塞是为了方便 runtime 调度,防止因为阻塞在系统调用上而失去了对 goroutine 的控制权。
转自:
gopherliu.com/2017/08/10/golang-tcp-socket/
- EOF -
Go 开发大全
参与维护一个非常全面的Go开源技术资源库。日常分享 Go, 云原生、k8s、Docker和微服务方面的技术文章和行业动态。
关注后获取
回复 Go 获取6万star的Go资源库
分享、点赞和在看
支持我们分享更多好文章,谢谢!
文章评论