golang tcp socket 那些事儿

2022年4月22日 219点热度 0人点赞 0条评论

【导读】本文是一篇 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 typeint 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([]byte1024)
   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(stringstring, 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(stringstring, 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(intintint) (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(stringstring, syscall.RawConn) errorerror {
 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 0nil
 }
 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 -

推荐阅读(点击标题可打开)

1、Go 中分布式锁学习笔记

2、Go Middleware指南及其原理

3、Graphql Go 基于Golang实践

Go 开发大全

参与维护一个非常全面的Go开源技术资源库。日常分享 Go, 云原生、k8s、Docker和微服务方面的技术文章和行业动态。

图片

关注后获取

回复 Go 获取6万star的Go资源库

分享、点赞和在看

支持我们分享更多好文章,谢谢!

8140golang tcp socket 那些事儿

root

这个人很懒,什么都没留下

文章评论