Freewind @ Thoughtworks scala java javascript dart 工具 编程实践 月结 math python english [comments admin] [feed]

(2015-01-10) Netty5中线程问题的分析与解决

广告: 云梯:翻墙vpn (省10元) 土行孙:科研用户翻墙http proxy (有优惠)

这段时间在开发remote pair工具时,我在server与client端都使用了Netty来接收和发送消息。但由于我对Netty的线程模型不了解,想得比较简单。虽然代码中某些地方做了同步,但并不仔细。

后来使用的时候,发现在一些简单的情况下没有发现问题,但一旦并发较多时就出了一些奇怪的问题,怎么都无法解决。

先说一下我的场景:

  1. 首先有一个server,使用netty作为socket服务器,可接受多个客户端连接
  2. 每个客户端都可以向服务器发送消息,更新服务器上持有的一个文档
  3. 服务器每收到一个信息,都会给它分配一个递增的version数字,同时按顺序把信息发送给所有的客户端,以便大家同步

当我在两台电脑上,使用两个客户端连接到某个server上,同时快速修改文档内容时,发现了一些完全没想到问题:

  1. 客户端收到的信息顺序乱了
  2. 信息内容有误

这两个问题让我觉得非常意外,因为我反复检查了代码,感觉这是不可能发生的!然而它就是发生了。

经常反复检查代码逻辑,我觉得唯一可能的原因就是多线程:

  1. 如果服务器端在发送消息时,是多线程发送的,有可能发生后发先至的情况
  2. 如果线程同步没有做好,服务器端在收到消息时,有可能根据某个中间态数据进行计算,导致信息内容有误

为了确定这两个问题,我没法逃避,只能把Netty的线程模型搞懂。看了很多资料、源代码以及各种尝试,总算确定它们都出现了,并在最后解决了。

Netty5的线程模型

网上有一些文章讲得比较深入,不过感觉太专业了,不是很容易看懂。我根据我的理解讲一下最重要的几点。

我们在使用netty创建一个server和client的时候,需要定义两个线程池,一个用来处理客户端连接(bossPool),一个用来在后续的操作中接收发送消息(workerPool)。

这两个池子的作用非常不同,我们只要理解了它们的用处,便可以容易给出一些较合理的值。如果用公司来比喻,可以想成:boss是接单的,worker是干活的。

第一个池子,bossPool,用来处理“客户端连接”。当一个新客户端来连的时候,需要一定的时间建立连接,另外有的可能还设置了一些验证步骤,这都将由第一个池子处理。所以,如果需要支持大量并发连接(比如http server),则需要把这个池子设大一点。但如果客户端比较少,则可直接使用默认值或者设为1(即只使用一个线程处理)。默认值是cpu的核数的2倍。

第二个池子,workerPool,用来处理所有的后续操作,比如接收消息,发送消息等。如果是长连接,不断有数据交互的那种,这个值就要设得大一些。如果短连接,或者server端不需要做什么复杂的操作,发送给客户端的消息比较小比较少,这个值可以小一点。

另外需要注意的是,如果server端经常需要做一些耗时的操作,比如操作数据库/访问网络等,这时通常会再创建一个单独的线程池专门异步执行这些任务,而不是占用前面的workerPool。那两个池子都是让netty用来快速处理消息用的。

每个连接的后续操作都在同一个线程中执行

对于一个多线程程序来说,数据的同步是一件很麻烦的事情。首先需要指出的是,Netty通过一些特别的做法,让这件事变得容易很多,使得我们在某些情况下不需要进行同步。但是如果我们对Netty的线程模型不了解的话,就只能按照普通的多线程情况写法去处理,需要增加一些不必要的synchronized或者锁。

Netty的做法是:每一个客户端一旦连接上,就会给它指定一条特定的线程,以后所有与它的交互操作,都将在、必须只在这条线程中进行。但这条线程可以处理多个客户端。

可以这样理解:某些银行为了给客户提供更好的服务,可以根据客户来电号码,找到上次与他通话的那名客服人员,继续为他服务。在理想的情况下,对于每一位客户来说,为他服务的客服人员都是同一个人,可以省掉很多事。而同一个客服人员,是可以为多位客户服务的。

正如银行的做法可以让客户少说很多重复的话,Netty的做法也可以让我们少写很多synchronized关键字。

如果Netty没有这么做,当某个客户端快速发送两条消息给服务器端,它们可能会转交给两条线程同时处理。这时,如果他们都需要对该连接持有的某个数据进行操作时,就必须小心的进行同步,否则可能出现各种不确定的错误。但如果Netty保证它们只能被同一条线程处理,那么一定是一先一后,顺序执行,并且由于始终在同一个线程中操作,也不会遇到内存栅栏的问题,让事情变得简单。

那是不是所有的情况都不需要sychronized了?当然不是。如果需要访问一些共享给所有连接的数据,还是需要进行合适的同步操作。但总比所有操作都要同步好很多。

如何让服务器端发送的消息始终保持先后顺序

在我前面所说的使用场景中,每当服务器端收到来自某个客户端的消息时,都要把version加1,并把该消息与新version的值一起发送给所有的客户端。我在客户端的代码假设收到的消息的version一定是递增的。

然而这个假设是很难实现的。按前面所说,两个客户端连接上来,可能恰好由同一个worker线程处理,也可能由不同的worker处理。然后,两个客户端在差不多同时各发送了一个消息过来。如果是前者,由于在同一个线程中执行,顺序是可以保证的;但对于后者,由于线程运行的不确定性,很可能收到晚来的消息线程反而先处理完,这样客户端收到的消息顺序就错了。

对于这种情况,我知道的有两种做法,分别在服务器端和客户端处理。

服务器端workerPool设为1

如果把workerPool的线程池大小设为1,即永远只有一条线程在跑,那么消息的顺序就可以简单的保证了。

但是这么做很可能会影响性能,比如有很多客户端连接的时候,一条线程忙不过来,导致后面所有的消息都不能及时处理。

在客户端排序

另一种做法是在客户端放一个队列,暂存收到的消息并对其进行排序。然后看看里面有没有顺序正确的消息,有就拿出来处理,否则的话继续等新的消息。

这样做虽然麻烦了一点,但是对服务器端的workerPool没有任何限制,性能不会受到影响。

我最终选择了这种方式。

bossPool和workerPool的示例代码

private val bossGroup = new NioEventLoopGroup()
private val workerGroup = new NioEventLoopGroup()

private val bootstrap = new ServerBootstrap()
bootstrap.group(bossGroup, workerGroup)
  .channel(classOf[NioServerSocketChannel])
  .childHandler(ChildHandler)
  .option(ChannelOption.SO_BACKLOG.asInstanceOf[ChannelOption[Any]], 128)
  .childOption(ChannelOption.SO_KEEPALIVE.asInstanceOf[ChannelOption[Any]], true)

bootstrap.bind(8080)

其中的bossGroupworkerGroup就是前面所说的bossPoolworkerPool

  1. 如果什么参数都没给,则是默认值:Math.max(1, SystemPropertyUtil.getInt("io.netty.eventLoopThreads", Runtime.getRuntime().availableProcessor()*2))
  2. 也可以指定值:new NioEventLoopGroup(1)或者new NioEventLoopGroup(10)
  3. 如果是0,则也是默认值

同步是一件很痛苦的事

在我花了几天的时间终于解决掉这些并发问题之后(希望真的解决了),我深深的觉得,依靠“同步”来进行并发编程是一件非常痛苦的事情。

以前一直听人说,使用“同步”方式进行并发编程是一件痛苦的事,以及使用actor这样的模型可以简化并发编程。但对于我来说,并没有太多真实的体会。

直到这一次,我算是真正深刻的体会到:使用同步方式,不仅仅要知道自己设计的代码的线程模型是什么样的,还需要知道所依赖的框架或者其它库的线程模型是什么样的,这样才能写出(可能)正确高效的代码。加上“可能”二字,是因为这样的多线程与同步问题,基本上是没法通过测试来保证正确性的,上线后出错也是非常难以重现及调试的。

我这次买了一本《Java虚拟机并发编程》的书,里面讲到如何使用其它的方式(比如STM, Actor等)代替“同步”方式。我的这次Netty调错的经验将会对我学习这本书带来非常有效的帮助。

comments powered by Disqus