上一节我们讲过要实现epoll监听用户建立的收发消息的FD,这样可以通过一个epoll协程监听多个FD,然而在大规模并发场景下, 单个协程监听所有 FD 依然存在明显的性能瓶颈。
当大量FD活跃的时候,内核需要将就绪的事件列表从内核空间拷贝到用户空间提供的 events 数组中。如果一次返回上千个就绪事件,这种内存拷贝的开销会随着并发量线性增长。而且由于epoll触发之后返回的是就绪的FD链表,假设有500个FD,那么此时只有一个epoll协程的话就只可以轮询处理,即时每个FD只是简单的读写操作,第500个FD也要等到前499个处理完才能得到响应。在处理这500个事件的过程汇总,内核可能又收到了1000个新事件。由于只有一个协程在工作,它无法及时再次调用epollwait去取新时间,这就会导致响应延迟剧烈抖动。这个问题可以通过引入协程池解决,epoll 协程只负责把就绪的FD扔进协程池。由于“扔”这个动作极快,池子里的多个 Worker 同时并发处理这 500 个任务,极大地提高了业务处理的吞吐量。而且FD多了之后内核的红黑树也会变大,这样注册或修改epoll事件的时候就需要加锁,当多个 Worker 试图同时修改同一个 epoll 句柄时,会导致内核态的锁竞争。
为了解决这个问题,我们需要引入多Reactor模式,既然一个epoll压力大,那我们就开N个。具体策略如下:
- 根据服务器的CPU核心数或者配置文件中规定的核心数,创建对应数量的 Epoll 实例(例如 8 核机器就创建 8 个)。
- 当新连接建立时,不再固定注册到同一个 Epoll,而是通过 Round-Robin(轮询) 算法,将其均匀分配给这 8 个 Epoll 实例。
- 为每个 Epoll 实例启动一个独立的监听协程,并使用 Go 的 runtime.LockOSThread() 将其 钉死在操作系统线程上 。这样可以避免 Go 调度器将关键的 I/O 协程在不同 CPU 之间搬来搬去,获得最低的调度延迟。
gateway代码分析
首先是网关层的结构体,包含如下字段:
type Server struct {
Epolls []*Epoll
NextEpoll int64
Pool *ants.Pool
Register *discovery.ServiceRegister
Addr string
Region string
// Stats
connNum int64
msgBytes int64
// User Mapping
UserMap sync.Map // map[string]*Client
// Config
MockLoad bool
}
Epolls是一个数组,存储了N个Epoll实例,每个实例对应一个独立的Poller线程,负责监听一部分连接的 I/O 事件。NextEpoll是一个原子计数器,当新连接进来时,通过NextEpoll % len(Epolls) 算法,决定把这个连接分配给哪个 Epoll 实例,实现连接层面的负载均衡。Pool就是基于ants库的协程池,当Epoll监听到数据就绪后,不会自己处理,而是把任务扔进这个池子。这实现了I/O线程与业务线程的分离 ,防止业务逻辑阻塞网络处理。
Register就是之前说过的网关层进行服务注册和汇报负载的桥梁,它会定期把当前的 connNum 和 msgBytes 上报给 etcd,这样 IpConfig 服务就能知道这台网关的负载情况。connNum和msgBytes是之前说过用来做负载均衡的字段。UserMap用来做业务路由,key是业务层用户ID(也可以是设备ID,后面实现了多设备登录再搓),value是Client对象指针,当后端服务器需要给某个用户发送消息时,网关通过这个 Map 快速找到该用户对应的 Client 对象(里面包含 socket FD),从而实现定向推送 。
接下来是构造函数:
func NewServer(addr, region string, etcdEndpoints []string, mockLoad bool) (*Server, error) {
numEpoll := runtime.NumCPU()
if numEpoll > 1 {
numEpoll = numEpoll / 2 // 留一半给业务逻辑
}
epolls := make([]*Epoll, numEpoll)
for i := 0; i < numEpoll; i++ {
ep, err := MkEpoll()
if err != nil {
return nil, err
}
epolls[i] = ep
}
pool, err := ants.NewPool(500)
if err != nil {
return nil, err
}
node := &domain.GatewayNode{
Addr: addr,
Region: region,
ConnectionNum: 0,
MessageBytes: 0,
}
reg, err := discovery.NewServiceRegister(etcdEndpoints, node, 5)
if err != nil {
return nil, err
}
return &Server{
Epolls: epolls,
Pool: pool,
Register: reg,
Addr: addr,
Region: region,
MockLoad: mockLoad,
}, nil
}
主要就是根据CPU核数创建多个Epoll实例,确保每个 CPU 核心都有一个专属的 Poller。之后初始化一个容量为500的worker协程池,用于处理具体的 I/O 读取和业务逻辑,防止阻塞 Epoll 线程。然后准备服务注册信息,构建一个描述当前网关状态的对象(初始负载为0),连接etcd,准备好租约,但 此时还未正式注册 (未调用 Register ),正式注册会在 Start() 方法中进行。
初始化好网关服务器之后就可以调用start开始监听请求并且建立长连接了。
tcpAddr, err := net.ResolveTCPAddr("tcp", s.Addr)
if err != nil {
log.Fatalf("ResolveTCPAddr error: %v", err)
}
listener, err := net.ListenTCP("tcp", tcpAddr)
if err != nil {
log.Fatalf("ListenTCP error: %v", err)
}
defer listener.Close()
log.Printf("Gateway started on %s (%s)", s.Addr, s.Region)
首先是将一个字符串形式的地址(如 "0.0.0.0:8080" 或 "localhost:9000")解析成程序可以识别的 *net.TCPAddr 结构体。然后开始监听这个端口,后续会调用listener.Accept()来持续接收客户端的请求。
if err := s.Register.Register(ctx); err != nil {
log.Fatalf("Failed to register: %v", err)
}
go s.reportLoadLoop(ctx)
s.StartEpollLoops()
之后就是调用服务注册,将当前网关信息注册进etcd并且开始建立租约,然后开启负载上报,启动一个后台协程,定期上报,把当前的连接数和吞吐量更新到etcd,传入 ctx ,这样当主程序退出时,这个循环也能感知到并自动退出。下一步启动Epoll监听,开启了N个绑定了OS线程的Poller协程:
func (s *Server) StartEpollLoops() {
for i, ep := range s.Epolls {
go s.epollLoop(ep, i)
}
}
func (s *Server) epollLoop(ep *Epoll, id int) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
log.Printf("Starting epoll loop %d", id)
for {
clients, err := ep.Wait()
if err != nil {
log.Printf("Epoll %d wait error: %v", id, err)
continue
}
for _, client := range clients {
c := client
s.Pool.Submit(func() {
s.handleClientEvent(c, ep)
})
}
}
}
注意在epollLoop方法的开头,我们调用了runtime.LockOSThread()。这行代码告诉 Go Runtime: 请把当前这个 Goroutine 永远固定在当前这个系统线程(M)上运行 ,直到函数退出。然后在循环内部就是阻塞调用ep.wait()方法,返回事件之后就循环遍历把任务提交到协程池进行处理。注意这里有一个经典go闭包陷阱,必须在循环内部创建一个局部变量获取client的拷贝,不然所有任务可能都会指向最后一个 client。
func (s *Server) handleClientEvent(c *Client, ep *Epoll) {
buf := make([]byte, 4096)
n, err := unix.Read(c.FD, buf)
if n == 0 || err != nil {
if err != unix.EAGAIN && err != unix.EWOULDBLOCK {
s.closeClient(c, ep)
}
return
}
atomic.AddInt64(&s.msgBytes, int64(n))
log.Printf("Received %d bytes from FD %d", n, c.FD)
if err := ep.Resume(c); err != nil {
log.Printf("Failed to resume client %d: %v", c.FD, err)
s.closeClient(c, ep)
}
}
之后我们将被唤醒的客户端放入协程池的任务队列等待处理,注意这个方法s.handleClientEvent(c, ep)。在这个方法中我们直接从Socket文件描述符中读取数据,把数据从内核态拷贝到用户态buf。再判断对端是否关闭连接或者有错误,此时直接关闭连接并且清理资源就好了。如果err == EAGAIN就表示当前缓冲区数据读完了,直接return就好,对应ET模式。
这里着重说一下方法ep.Resume(c)。由于Epoll实例触发之后直接由协程负责从socket读取数据,这样的好处就是完全解放Epoll实例的压力,提高及时性。因为之前都是Epoll自己读完然后再放到协程池解析的,可能复制数据压力大导致下一次进入Epoll时已经过了相对久的事件,这样会造成用户感知的卡顿。像我们这样全部交给协程池去做可以让Epoll快速返回,因为这时候就不需要读取了,只需要把任务放到协程池就可以。但是这样会造成竞争问题,比如说一个socket被触发,此时这个socket被分发给A协程处理,之后socket很快就返回了,但是A协程还没读取完的时候,这个socket又被触发了,此时这个socket被分发给B协程处理,所以A、B两个协程就产生了竞争,可能会造成业务逻辑重复。
所以我们这里使用ep.Resume(c),因为我们在注册时使用了 EPOLLONESHOT ,内核在通知完一次后就会自动屏蔽 这个 FD(下面将epoll代码的时候讲),这一步会导致当一个 Socket 触发事件并被某个线程取走处理后,内核会禁止该 Socket 再触发任何事件。
再回到start函数中,下一步就是开启accept循环,获取新建立的连接,然后每个连接的FD都下发给底层的Epoll数组去建立监听关系。
go func() {
for {
conn, err := listener.AcceptTCP()
if err != nil {
// Check if listener is closed
select {
case <-ctx.Done():
return
default:
log.Printf("Accept error: %v", err)
continue
}
}
if err := s.handleNewConnection(conn); err != nil {
log.Printf("Handle connection error: %v", err)
conn.Close()
}
}
}()
当新客户端完成三次握手之后,AcceptTCP调用就会返回一个连接对象,拿到连接对象后,立刻调用s.handleNewConnection(conn),这个函数就是拿到FD然后注册到Epoll中去。
func (s *Server) handleNewConnection(conn *net.TCPConn) error {
f, err := conn.File()
if err != nil {
return err
}
fd := int(f.Fd())
if err := unix.SetNonblock(fd, true); err != nil {
f.Close()
return err
}
client := NewClient(fd, f)
idx := atomic.AddInt64(&s.NextEpoll, 1) % int64(len(s.Epolls))
ep := s.Epolls[idx]
if err := ep.Add(client); err != nil {
f.Close()
return err
}
conn.Close()
atomic.AddInt64(&s.connNum, 1)
return nil
}
conn是标准库的对象,调用File()会调用系统的 dup 系统调用,复制一个新的文件描述符 f 。我们把这个文件描述符设置为非阻塞的,因为Epoll是事件驱动的,如果读写操作阻塞了,整个 Poller 线程就会卡死,所以读写这个FD的时候,如果没有数据或缓冲区满了,直接返回错误(EAGAIN)。接下来就需要通过轮询将这个fd均匀的注册到每个Epoll实例上,其中ep.Add(client)就是将 FD 注册到选中的 Epoll 实例中。
由于go的net.TCPConn内部封装了一套很复杂的逻辑(自动重试、超时控制、关联到 Go 的全局 Netpoller)。如果不调用 conn.Close() ,Go 的 Netpoller 和我们的 Epoll 就会 同时监听 同一个 Socket。当数据来的时候,两边都会被唤醒,产生竞争,所以我们需要把原先的conn关闭,只留下刚才注册的Epoll实例中的那个。
然后就是优雅退出的逻辑:
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
log.Println("Shutting down gateway...")
s.Stop()
func (s *Server) Stop() {
if s.Register != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.Register.Stop(ctx); err != nil {
log.Printf("Failed to stop register: %v", err)
}
}
for _, ep := range s.Epolls {
if err := ep.Close(); err != nil {
log.Printf("Failed to close epoll: %v", err)
}
}
if s.Pool != nil {
s.Pool.Release()
}
}
这段没什么好说的,就是释放资源。
Epoll代码分析
type Epoll struct {
fd int
lock *sync.RWMutex
connections map[int]*Client // Map FD to Client
}
fd就是通过系统调用创建出来的Epoll实例的文件描述符,lock是控制并发安全的,因为Accept线程在有新用户连接时,会调用ep.Add(client)往 Map 里插入一条数据,worker线程当用户断开连接或出错时,会调用 s.closeClient -> ep.Remove(client) ,从 Map 里删除一条数据,所以需要锁来保护。connections这个map是用来帮助网关知道消息是哪个用户发送的,之前gateway的map是用来做上层业务路由找到要发送对象的fd的。
构造函数没什么说的:
func MkEpoll() (*Epoll, error) {
fd, err := unix.EpollCreate1(0)
if err != nil {
return nil, err
}
return &Epoll{
fd: fd,
lock: &sync.RWMutex{},
connections: make(map[int]*Client),
}, nil
}
Add和Remove操作就是加锁然后进行系统调用在Epoll实例上注册或者移除FD,然后再更新Map就可以了。
func (e *Epoll) Add(client *Client) error {
e.lock.Lock()
defer e.lock.Unlock()
fd := client.FD
err := unix.EpollCtl(e.fd, syscall.EPOLL_CTL_ADD, fd, &unix.EpollEvent{Events: unix.POLLIN | unix.POLLHUP | unix.EPOLLONESHOT, Fd: int32(fd)})
if err != nil {
return err
}
e.connections[fd] = client
return nil
}
func (e *Epoll) Remove(client *Client) error {
e.lock.Lock()
defer e.lock.Unlock()
fd := client.FD
err := unix.EpollCtl(e.fd, syscall.EPOLL_CTL_DEL, fd, nil)
if err != nil {
return err
}
delete(e.connections, fd)
return nil
}
之后就是Resume方法,首先是加读锁,因为这个函数中并不会修改Map,只是为了防止在 Resume 的过程中连接被 Remove 掉导致 FD 无效),之后就修改Epoll实例中对应的监听事件,由于之前的 ONESHOT 触发后,内核已经把这个 FD 从“就绪队列”中屏蔽了(虽然还在红黑树里,但不通知了),我们调用这个操作就相当于恢复Epoll实例对此的感知。
func (e *Epoll) Resume(client *Client) error {
e.lock.RLock()
defer e.lock.RUnlock()
fd := client.FD
err := unix.EpollCtl(e.fd, syscall.EPOLL_CTL_MOD, fd, &unix.EpollEvent{Events: unix.POLLIN | unix.POLLHUP | unix.EPOLLONESHOT, Fd: int32(fd)})
return err
}
接下来就是wait方法,这是事件循环的主要逻辑,负责阻塞等待内核事件,并将其转化为go对象。
func (e *Epoll) Wait() ([]*Client, error) {
events := make([]unix.EpollEvent, 100)
n, err := unix.EpollWait(e.fd, events, 100)
if err != nil {
return nil, err
}
e.lock.RLock()
defer e.lock.RUnlock()
var clients []*Client
for i := 0; i < n; i++ {
fd := int(events[i].Fd)
client := e.connections[fd]
if client != nil {
clients = append(clients, client)
} else {
// 如果找不到client,可能是已经移除了但事件还在队列中,或者是其他异常情况
// 尝试从epoll中移除这个fd以防万一
unix.EpollCtl(e.fd, syscall.EPOLL_CTL_DEL, fd, nil)
}
}
return clients, nil
}
首先分配一块内存,用来存放内核返回的事件,这里一次最多取100个,多了下次再取,然后就进行系统调用unix.EpollWait,返回就代表内核有事件触发。接下来就查Map,加读锁保证并发安全,遍历内核返回的 n 个事件,拿到发生事件的FD,通过Map查到这个FD对应的Client对象。如果在Map中查不到,这通常意味着竞态条件,比如在 EpollWait 返回后、加锁前的那一瞬间,另一个线程调用 Remove 把这个连接删了。安全起见,调用unix.EpollCtl(e.fd, syscall.EPOLL_CTL_DEL, fd, nil)手动告诉内核别再监控这个FD了,防止它反复触发导致空转。
之后就是Epoll实例的close逻辑,也没有什么好说的:
func (e *Epoll) Close() error {
e.lock.Lock()
defer e.lock.Unlock()
for _, client := range e.connections {
unix.Close(client.FD)
}
return unix.Close(e.fd)
}
测试
注意进行压测的时候需要修改linux内部的临时端口数量和FD限制:
sudo sysctl -w net.core.somaxconn=65535
sudo sysctl -w net.ipv4.tcp_max_syn_backlog=65535
sudo sysctl -w net.ipv4.ip_local_port_range="1024 65535"
sudo sysctl -w net.ipv4.tcp_tw_reuse=1
上面测试截图中右边显示Success的数目不一样是因为发起是主线程的动作,每次启动1000个协程去连接,一旦1000 个协程启动完毕,主线程就立即打印 Initiated 1000 。后面显示成功的是1000个协程的异步回调结果,当你打印日志的那一瞬间,可能只有 981 个协程完成了握手,剩下的 19 个还在路上(SYN_SENT 状态)。
主要测试逻辑如下:
for i := 0; i < *count; i++ {
<-rateLimit
wg.Add(1)
go func(id int) {
defer wg.Done()
conn, err := net.DialTimeout("tcp", *addr, 5*time.Second)
if err != nil {
atomic.AddInt64(&failCount, 1)
if atomic.LoadInt64(&failCount) <= 5 {
log.Printf("Conn %d failed: %v", id, err)
}
return
}
// Keep connection open
atomic.AddInt64(&successCount, 1)
// Optional: Send some data
// conn.Write([]byte("ping"))
// Hold the connection
buf := make([]byte, 1)
// Read will block until server closes or error
conn.Read(buf)
conn.Close()
}(i)
if i%1000 == 0 && i > 0 {
log.Printf("Initiated %d connections... (Success: %d, Fail: %d)", i, atomic.LoadInt64(&successCount), atomic.LoadInt64(&failCount))
}
}