重构即时IM系统6:网关层(上)代码

0 阅读13分钟

上一节我们讲过要实现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 服务就能知道这台网关的负载情况。connNummsgBytes是之前说过用来做负载均衡的字段。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)
}

测试

image-20260120163536056

注意进行压测的时候需要修改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))
    }
}