并发难 | 池里究竟该放多少线程?

并发 思考

2016-07-09 13:08 PM

最近在项目里做了一个数据同步的功能。需要把两个数据库里的数据做同步,在业务不繁忙的时候跑一次,核对库里每一条数据,主要是为了保证数据最终一致,做的一个保底工作。之前项目里同步的代码是单线程的,我改成了基于线程池的,但是在上线前的一些问题却引起了我的思考。


现象

程序的代码还是比较简单的,大概类似下面:

  1. // 两个参数分别为线程数量和总任务数
  2. p := pool.NewPool(goroutineCount, len(userIds))
  3. for _, userId := range userIds {
  4. p.Queue(func(job *pool.Job) {
  5. userId := job.Params()[0].(int64)
  6. tx, err := sess.Begin()
  7. if err != nil {
  8. panic(err)
  9. }
  10. defer tx.RollbackUnlessCommitted()
  11. if err := dao.SyncSingleUser(tx, userId); err != nil {
  12. panic(err)
  13. }
  14. if err := tx.Commit(); err != nil {
  15. panic(err)
  16. }
  17. }, userId)
  18. }
  19. errOccurs := false
  20. for result := range p.Results() {
  21. if err, ok := result.(*pool.ErrRecovery); ok {
  22. glog.Errorln(err)
  23. errOccurs = true
  24. }
  25. }
  26. if errOccurs {
  27. return
  28. }

为了确定线程的数量,我逐步增加线程数记录系统各项数值。下面是在一台 RMBP 上做的测试。

T QPS CPU DBCPU
1 9000 37 81
2 14000 60 150
3 15500 70 195
4 16500 90 260

可以发现随着线程数增加,性能会逐步提高,但是当线程数提高到一定程度之后,性能不增反降(这部分数据没放)。如果继续增加线程数进行测试,会发现系统的某些性能指标会被耗尽,程序开始报错。

  1. recovering from panic: dial tcp 127.0.0.1:3306: getsockopt: operation timed out

为什么会出现这种情况呢?golang 不是号称百万数量级的 goroutine 吗?在讨论这些问题之前,先回顾一下 goroutine 的设计与实现。


goroutine 设计

事实上,在 golang 中的 goroutine 已经不是传统意义上的线程了。传统的操作系统提供的线程不能过度使用,超过一定数量之后会对性能造成影响。而 golang 提供的 goroutine 相当于一种 green thread。

Green thread 和 NodeJS 的异步回调方案有类似的地方,都在底层调用的系统的异步 IO 系统调用。

  • 异步回调方案:所有 IO 操作的 API 都设计成异步回调形式,底层调用异步的系统调用(如epool、kqueue等)。同时,也可能提供同步版本的 IO 操作(如fs.readFileSync)
  • Green thread 方案:所有 IO 操作底层实际上事异步调用,但是在语言中却表现的像一个同步调用。当 IO 操作需要等待时,语言的 runtime 自动调度系统级线程到另一个 Green thread 中。写代码的时候感觉好像是同步的,仿佛在同一个线程完成的,但实际上系统可能切换了线程,但对程序无感。

由此可见,Green thread 在语言设计上比异步回调方案要略胜一筹,不会出现“冲击波”的现象(具体在 NodeJS 中如何避免“冲击波”代码可以看我这篇文章await & async 深度剖析),但在性能上实际是差不多的,都避免了大量使用操作系统级的线程带来的性能问题,同时又能充分的利用 CPU。

但是,今天我想谈的问题并不是 CPU 利用率/线程数量的问题,这个问题已经被上述两种设计方案比较完美的解决了。


CPU 以外的资源瓶颈

在实际中,更多遇到的是 cpu /线程数量之外的资源瓶颈。比如锁、数据库链接、tcp链接。举个例子,假如做个爬虫应用,每个 goroutine 爬一个网页,golang 虽然号称百万级别的 goroutine ,但是你每个 goroutine 里面创建一个 tcp 链接,不到 5 万个 goroutine 就会把系统的 tcp 资源耗尽。

回到文章最上面的情况,在 goroutine 里调用了 dao.SyncSingleUser 函数,这个是一个数据库操作,不可避免的会有 socket 操作、硬盘操作。如果将所有数据库操作都无脑的放到 goroutine 中执行,当资源出现瓶颈之后,大量 goroutine 会阻塞或报错。

因此,我在项目里使用了一个 goroutine 池,来确保不会过多使用系统资源导致崩溃。那么问题来了,池里究竟该放多少线程

又回到了最初使用系统线程时遇到的问题,不过这次导致问题的不再是线程资源,而是其他资源瓶颈(如 tcp、数据库链接 等)。这些资源比线程资源更加复杂,更加难以把控。更加难做 benchmark,也就更难找出一个通用的方法来解决实际场景的问题。


没有银弹

思考过后,我在网上开始搜索解决方案。发现这篇文章的作者和我做了类似的思考:《并发之痛 Thread,Goroutine,Actor》。作者最后得出 Actor 模型能解决一些问题(不过我才疏学浅至今不怎么理解 Actor 模型),但并发带来的问题还远远没到解决的程度。

革命尚未成功 同志任需努力

所以说,软件工程没有银弹,路漫漫其修远兮,吾将上下而求索。


发表于 2016-07-09 13:08 PM,最后更新于 2017-05-24 10:59:37 AM。

本文使用 署名 - 非商业性使用 - 相同方式共享 4.0 国际 协议


评论加载中...

首页