0.2.0 Goroutine 优化

整体方案

  • 底层使用epoll + 事件驱动编程模型,替代golang默认的epoll + 协程阻塞编程模型。
  • IO协程池化,减少大量链接场景协程调度开销和内存开销。
  • 开关配置,用户可根据自身的场景,决定使用何种协程模型。

细节变更

epoll + 事件驱动编程模型

connection开启读写处理时,若当前模式为netpoll,则将自身的可读事件及其对应处理函数添加到全局的epoll wait的event loop中。

示例改造代码:

  1. //create poller
  2. poller, err := netpoll.New(nil)
  3. if err != nil {
  4. // handle error
  5. }
  6. // register read/write events instead of starting I/O goroutine
  7. read, _ := netpoll.HandleReadOnce(c.RawConn())
  8. poller.Start(read, func(e netpoll.Event) {
  9. if e&netpoll.EventReadHup != 0 {
  10. (*poller).Stop(read)
  11. // process hup
  12. ...
  13. return
  14. }
  15. // Read logic
  16. go doRead()
  17. // Enable read event again
  18. poller.Resume(read)
  19. })
  20. })

在链接关闭时,若此链接以netpoll模式运行,则需要将其注册的读写事件从event loop中注销掉。
示例代码:

  1. // wait for io loops exit, ensure single thread operate streams on the connection
  2. if c.internalLoopStarted {
  3. // because close function must be called by one io loop thread, notify another loop here
  4. close(c.internalStopChan)
  5. } else if c.eventLoop != nil {
  6. c.eventLoop.Unregister(c.id)
  7. }

IO协程池化

可读事件触发时,从协程池中获取一个goroutine来执行读处理,而不是新分配一个goroutine。以此来控制高并发下的协程数量。

示例改造代码:

  1. //create poller
  2. poller, err := netpoll.New(nil)
  3. if err != nil {
  4. // handle error
  5. }
  6. // register read/write events instead of starting I/O goroutine
  7. read, _ := netpoll.HandleReadOnce(c.RawConn())
  8. poller.Start(read, func(e netpoll.Event) {
  9. // No more calls will be made for conn until we call epoll.Resume().
  10. if e&netpoll.EventReadHup != 0 {
  11. (*poller).Stop(read)
  12. // process hup
  13. ...
  14. return
  15. }
  16. // Use worker pool to process read event
  17. pool.Schedule(func() {
  18. // Read logic
  19. doRead()
  20. // Enable read event again
  21. poller.Resume(read)
  22. })
  23. })

需要注意的是,由于conn.write可能会阻塞协程,因此对于写操作的池化,我们采取了更为宽松的策略:池化的常驻协程数量仍然是固定的,但是允许新增临时协程。
池化代码:

  1. type SimplePool struct {
  2. work chan func()
  3. sem chan struct{}
  4. }
  5. func NewSimplePool(size int) *SimplePool {
  6. return &SimplePool{
  7. work: make(chan func()),
  8. sem: make(chan struct{}, size),
  9. }
  10. }
  11. func (p *SimplePool) Schedule(task func()) {
  12. select {
  13. case p.work <- task:
  14. case p.sem <- struct{}{}:
  15. go p.worker(task)
  16. }
  17. }
  18. // for write schedule
  19. func (p *SimplePool) ScheduleAlways(task func()) {
  20. select {
  21. case p.work <- task:
  22. case p.sem <- struct{}{}:
  23. go p.worker(task)
  24. default:
  25. go task()
  26. }
  27. }
  28. func (p *SimplePool) worker(task func()) {
  29. defer func() { <-p.sem }()
  30. for {
  31. task()
  32. task = <-p.work
  33. }
  34. }

开关配置

新增一个全局配置来控制链接的读写协程运作模式:netpoll+协程池或者链接级别的读写协程。
示例代码:

  1. func (c *connection) Start(lctx context.Context) {
  2. c.startOnce.Do(func() {
  3. if UseNetpollMode {
  4. c.attachEventLoop(lctx)
  5. } else {
  6. c.startRWLoop(lctx)
  7. }
  8. })
  9. }

示例配置:

  1. "servers": [
  2. {
  3. "use_netpoll_mode": true,
  4. }
  5. ]

性能对比

goroutine数量

10,000 长连接 优化前 优化后
goroutine 20040 40

内存占用

主要是goroutine stack的节省。

10,000 长连接 优化前 优化后
stack 117M 1M

大量链接场景下的性能

10000长连接,300qps

优化前 优化后
CPU 14% 8%

10000长连接,3000qps

优化前 优化后
CPU 52% 40%

10000长连接,10000qps

优化前 优化后
CPU 90% 91%

压测场景下的性能

指标 IO协程 netpoll
QPS 23000 22000
CPU 100% 100%
RT 9ms 10ms

分析

对于非活跃链接占据多数的场景,可以有效降低goroutine数量、内存占用以及CPU开销。但是对于活跃链接较多、乃至少量链接大量请求的场景,会出现轻微的性能损耗。
因此我们提供了可选配置UseNetpollMode,使用方可以根据自身的场景自行决定使用何种协程模型。