Thrift(Java版)到网络编程(二)--多线程

写在前面

前一篇看似写了很多,但还只是涉及一些基本概念和最简单的“单线程同步阻塞”模式。一句话带出来三个概念:单线程VS多线程,同步VS异步,阻塞VS非阻塞。后面两个概念还经常被混用,甚至很多人都不区分。篇幅有限,本文重点讨论Java多线程。后面两个,可能又得写个续篇了。

一、 线程

线程(英语:thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。线程一般是操作系统的一部分。(来自维基百科

不太想赘述进程、线程、协程等概念。线程作为“运算调度”的最小单位,意味着线程之间,主要是竞争CPU资源。跟进程相比同一个进程内多个线程之间,是可以共享一些诸如内存等资源的。当然线程会比进程更轻量级,所以切换成本也更低。

为什么要多个线程?此处省略1W字…

这里有一篇写的非常不错的文章《Java程序中的多线程》,分享给大家。对于入门同学们还是非常不错的。如果对这部分非常熟悉了,直接跳过本章也是很明智的。

下面列几个基本概念:

1. 线程状态

发现还得把下面这张图搬出来(上一篇其实已经帖过一次了):

图1. Java线程状态图

既然重新贴了就啰嗦下。这几个状态都什么含义呢?

这里TIMED_WAITING对应的是上图的Sleeping。翻译工作我就不做了,具体状态迁移等也就不啰嗦了。大家看图就好。有几个关键的操作:start(), run(), sleep(), wait(),notify(), notifyAll()。另外有个东西叫monitor。前面几个相信大家都见过。没见过也不要紧,往下看。我们先看看monitor是个什么鬼?

2. Monitor

Monitor是拥有一个Lock和一个wait set的实例。每个Object及其子类的实例都拥有一个Lock和wait set,而这两个东东是在JVM里维护的。Monitor机制则是指对一个对象的并发访问控制机制。上面提到的wait,notify等都是基于monitor机制工作的。

上面描述有点绕,试着换个方式说:每个Object实例其实都是一个monitor。每个monitor都会维护一个“许可”(实际上就是个Lock)。当一个线程对此对象进行访问时(当此对象被并发保护起来后, guarded),必须先向此monitor申请许可。而未能获得此对象的许可的线程只能阻塞(block),直到获得此许可 。当线程完成对该对象的操作后,会释放此许可 。这就是传说中的互斥访问机制。那么一个对象为啥要被并发保护呢?
关键是多个线程之间该对象内存是共享的。两个线程同时修改同一个资源会有各种问题(此处省略10k字)。

可以参考电子书《Concurrent Programming in Java》。其他很多基本概念都可以参考此书。能写一本书,说明这块概念还是不少的。本文作者理解不够深,所以也就没法用更精炼的语句讲明,还请谅解。我们先知道有个东东叫Monitor其实很多时候就足够了。

3. Synchronized关键字

Synchronized 关键字是基于java Intrinsic lock(固有锁?)实现的。也有人把intrinsic lock跟monitor等同而论。所以,他跟Lock没有本质区别。具体用法等我就不多说了。因为synchronized是基于对象的固有锁实现,所以一个对象内所有的synchronized块都是公用一把锁。

4. Lock & Condition接口

Lock是java.util.concurrent下的一个类。Synchronized是个关键字。主要区别是Synchronized用起来更简单,不用考虑释放之类的问题。而前者功能更强大。其中最主要的扩展就是Condition. Condition主要提供两个接口:await和signal。分别对应Object的wait和notify。区别是Condition可以实现更细粒度的控制。Lock使用最广泛的实现类应该是ReentrantLock(比如ConcurrentHashMap就是基于ReentrantLock实现的)。

5. Object类

Java里辈分最高的类,所有类的公共祖先。 他提供了三个方法:wait,notify和notifyAll。可以对应图1,看一下。当一个线程调用wait方法时,进入到waiting状态,直到其他线程调用此对象的notify或notifyAll方法才能重新进入Runnable状态等待操作系统调度。如前面描述,这里用到的应该是对象的固有锁。因此这个控制是对象粒度的。那么对应的Condition用到的应该是wait set,因此用Condition可以实现一个对象多个维度的并发控制。

了解JVM的同学都知道在对象头里,有锁相关的标记位。这里就不展开了,大家可以参考此文

6. Thread类

终于说到Thread了。他是个类,实现了Runnable接口。用的时候可以直接继承,也可以自己实现下Runnable接口,并把它当做入参传进来。一般推荐后者,原因当然是java不支持多继承。

核心的几个接口:start, join, sleep, yield等,就不一一展开了。Stop已经被Deprecated了,所以也不用去管它,当然也不建议用了。

每个线程都有优先级。默认用的是创建此线程的优先级。我们可以设置,java有十档。当然设置是否生效,还得看OS,比如OS优先级少于10个会发生生么呢?(linux2.6后实时线程似乎是分配了100个优先级,所以应该够用)。

线程可以设置为守护线程(daemon)。JVM只有当所有运行的线程都是守护线程时才会真正退出。也就是说,只要有一个工作线程还在工作,那JVM就不会真正退出,而忽略守护线程是否在执行。至于有木有其他区别,作者还没看到。

7. Runnable & Callable接口

Callable和Runnable最大的区别是callable的call方法可以返回值,也能抛异常。Thread因为实现的是Runnable,所以也无法抛出未捕获异常。所以Thread类单独提供了setUncaughtExceptionHandler方法来设置未捕获异常处理回调函数。

Callable和Runnable本质区别也就这么多。

8. Future接口

Future用来表示“未来”将得到的计算结果。可以通过isDone去查看任务是否完成。也可以试图取消(cancel)任务。通过get可以获取计算结果,如果还没计算完成就会block等待。

9. Volatile关键字

Volatile告诉JVM该变量必须从主存读写。也就是说不能用寄存器,以免多个线程并行读写时,因为访问了本地缓存(比如寄存器)而导致的逻辑错误。详细的可以看看此文

能想到的基本概念终于念叨了一遍。都不深,大家如果想细嚼,可以看看上面推荐的那本书,绝对够细^_^。

二、 ThriftServer之TThreadPoolServer

上一篇,我们搞了个简单的Thrift server demo。他最主要问题是单线程的。本节先搞个简单的多线程例子。Client代码不变。Server代码大概如下:

handler = new HelloHandler();

processor = new Hello.Processor<>(handler);
TServerSocket.ServerSocketTransportArgs transportArgs

    = new TServerSocket.ServerSocketTransportArgs()

    .port(port)

    .clientTimeout(timeout);

TServerTransport serverTransport = new TServerSocket(transportArgs);

TThreadPoolServer.Args args = new TThreadPoolServer.Args(serverTransport)

    .minWorkerThreads(5)

    .maxWorkerThreads(256)

    .processor(helloProcessor)

    .protocolFactory(new TBinaryProtocol.Factory());

TServer tServer = new TThreadPoolServer(args);

tServer.serve();

跟单线程Server比起来,最大的区别是引入了TThreadPoolServer。当然还有一堆参数。那我们就来看看TThreadPoolServer到底是个什么东东。

TThreadPoolServer继承自TServer(上文简单介绍过了)。 细心的同学应该已经看到TThreadPoolServer自带一个ExecutorService。再看看serve()方法:

while (!stopped_) {

    TTransport client = serverTransport_.accept();

    WorkerProcess wp = new WorkerProcess(client);

    int retryCount = 0;

    long remainTimeInMillis = requestTimeoutUnit.toMillis(requestTimeout);

    while(true) {

      try {

        executorService_.execute(wp);

        break;

      } catch(Throwable t) {}
   }
   
}

大致流程就是accept一个Transport=>把这个Transport转成WorkerProcess => 将WorkerProcess放到executorService执行。

其中WokerProcess实现了Runnable接口。而run的实现跟单线程Server没有太多区别。

上面的例子,我们没有传入ExecutorService。这时候用的是默认实现:

private static ExecutorService createDefaultExecutorService(Args args) {

  SynchronousQueue<Runnable> executorQueue =

    new SynchronousQueue<Runnable>();

  return new ThreadPoolExecutor(args.minWorkerThreads,

                                args.maxWorkerThreads,

                                args.stopTimeoutVal,

                                TimeUnit.SECONDS,

                                executorQueue);

}

ThreadPoolExecutor是java concurrent包里提供的一种线程池实现。所以,想把TThreadPoolServer搞清楚,必须得先搞清楚Executor框架了。并且似乎除了这个,Thrift本身也没多做什么。

TThreadPoolServer各个参数的含义,我们最后再说一下。

三、 多线程容器

裸写线程相关逻辑,管理线程生命周期,是个非常繁琐的事情,并且容易出错(写C的同学应该大部分都有这个痛苦的经历)。Java5引入了Executor框架,以方便我们更轻松的多线程场景。

用Intellij Idea生成了核心的几个接口和类之间的关系,如下:

图2. Executor框架核心类图

1. Executor

该接口只提供了一个接受Runnable的方法:submit。目的是解耦任务的提交和其具体执行。用户不用在关心线程的创建、调度等细节。

2. ExecutorService

ExecutorService继承自Executor。它支持了关闭Executor功能。Submit接口也可以返回Future了。这样可以更好的处理异步任务。

这里shutdown和shutdownNow的区别是:1)shutdown后不再接受新的任务,等待进行中的任务一一结束。没有返回值。2)ShutdownNow停止接收新的任务且尝试停止所有进行中的任务。ShutdownNow还会返回当前等待的执行的任务列表。尝试终止任务实际上是调用Thread. Interrupt(),所以无法确保终止一定成功。

3. ThreadPoolExecutor

这应该是应用最广泛的Executor。看下构造函数:

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) {

1)corePoolSize:核心线程数。当没有设置allowCoreThreadTimeOut时,及时闲置,也会保留该数量的核心工作线程(开始时一个个创建)。

2)maximumPoolSize:最大工作线程数。当核心线程都忙碌时,再有任务加入,会生成新的工作线程,直到该数量。超过此数量就会在队列中等待,或者被拒绝。

3)keepAliveTime,unit:实际上是corePoolSize以外的线程从队列获取任务时的等待时间。也就是说非核心线程保持闲置的时长。unit是其单位。

4) workQueue:阻塞的等待队列。在调用execute方法时,任务(Runnable)会被offer给该队列,等待被调度。常用的有三类Queue,这里就不翻译了,大家可以参考注释:

5) threadFactory:生成新线程的工厂。默认用的是Executors.defaultThreadFactory()。同一个group,非deamon,NORM优先级,线程名类似“pool-N-thread-M”。

6) handler:当等待队列满了或者处理线程爆了,可以通过这个handler进行捕获并处理异常。默认什么也不做。

该类说复杂复杂,说简单也简单。关键数据结构就是一个线程池 + 一个队列。当队列中有任务到来,会fork一个线程进行处理。当然为了减少fork线程的代价,优先使用此前创建过切闲置的线程。工作线程会保持闲置一段时间,等待新的任务到来。为了不至于fork出太多线程把系统资源耗尽,所以需要有个maximumPoolSize限制。为了防止线程一直闲置,占用资源,又有主动销毁的机制,这个是通过keepAliveTime控制的。我们大部分时候系统负载是相对稳定的,所以有个核心线程的概念,这部分一般是不会主动销毁的。当然非要把核心线程也销毁,也是可以通过allowCoreThreadTimeOut实现的。不过一般都不会这么做。

4. ScheduledThreadPoolExecutor

扩展自ThreadPoolExecutor。它支持一个任务被delay一定时间后执行或周期执行。当然这里的delay时间是没有保证的。因为根据上面的描述,任务在队列里的等待时长是没法精确控制的。只能保证在delay之后某个时间点会被执行。关键的结构是ScheduledFutureTask,他继承了FutureTask实现了RunnableScheduledFuture。有兴趣的同学可以自行研究下。这里就不扩展了。

5. ForkJoinPool

Fork/Join的思想来源应该是传说中的分治。当一个大任务来了,我们希望能把他拆分成多个子任务,最终把各个子任务的结果进行归并,就可以得到最终的结果。ForkJoinPool就是实现该机制的一个线程池。Java7而他接受的任务需要是ForkJoinTask(当然他也实现了把一个callable或runnable转成ForkJoinTask的方法,但是我觉得这么做没什么收益)。当一个ForkJoinTask提交给ForkJoinPool后,ForkJoinPool里的线程会共同来完成其子任务,从而提高pool里线程的使用率。

6. Executors

是Executor的工厂类。在实际使用中我们可以优先考虑该工厂类,就不用太关注上面所述的一些细节了。主要的工厂方法如下:

这里用到的具体类,上面基本都已经讲过了,就不再啰嗦了。可以认为该工厂只是对一些常用的使用场景做了一些定制。

Executors看这篇文章就够了,写得很不错。

四、 回头看TThreadPoolServer

写了这么多,作者其实只是希望能把TThreadPoolServer说明白一点而已。

作为应用方,我们最多关注的也只有其参数。所以本章我们回过来看一下TThreadPoolServer的一些参数。具体可配置的参数如下:

再看下默认的ExecutorService实现:

这些参数的设置实际上很烦,跟你的业务场景(CPU密集还是IO型),负荷,机器资源强相关。最有效的办法是放到线上,通过压测或实际业务情况看指标进行相应微调。

1. minWorkerThreads:核心工作线程数。上面讨论过了。建议设置成CPU个数的倍数。

2. maxWorkerThreads:我们的业务场景一般是相对固定的。所以这个值在资源允许的情况下,我们倾向于设置成跟minWorkerThreads相同或其两倍。再多也只能起到一些请求缓冲作用,意义不太大。

3. stopTimeoutVal:保持默认就好。如果上面两个参数设置成一样,这个参数其实没意义。

4. requestTimeout:这个不是pool的参数,而是一个请求被Accept到能够被execute(只是被接收,而不是执行成功)成功的时间。该值可以小一点,因为pool满了,再加也意义不大。代码如下:

5. beBackoffSlotInMillis:这个参数牛X了,因为没看懂。这里有个算法,为的是解决网络重试导致的拥塞,叫“二进制指数退避”算法。这个玩意跟这个算法有关。具体可以参考wiki。如果大家也跟我一样看不懂,那就别动他了,保持默认就好。

6. pool等待队列:默认用的是SynchronousQueue,因此实际上该队列不会缓存任务,而是直接将任务分配给线程池。如果线程池没有空闲线程且已经到了最大线程数,那么就会reject。

很多时候这些参数的设置真的是个繁琐的事情(此处GO党估计又要鄙视JAVA了)。大家更多是要做到对框架本身和自身业务有深入的了解,才能够找到一个较优的参数配置。不过往好了看呢,一般的web业务,这些参数调整余地也没那么大。把单个线程的处理能力和平均耗时优化好,很多时候比调优这些参数更有意义。

当然Thrift还有很强的扩展能力,比如上面的等待队列想换成别的,只要自己实现个ExecutorService,并在构造TThreadPoolServer时带入即可。大家可以尝试下。

后记

这篇拖得时间也够长的。借口当然是最近比较忙了。不过保持写点东西的习惯还是很重要的。希望后面一直能够坚持。下一篇希望能够把一些异步,NIO之类的东东拿来讨论讨论,不过说实话用的不多,所以也可能是别的题目。欢迎大家指出作者理解不对的地方,也欢迎各种讨论、批评、指教。

还没看过战狼2,印度还不撤军。立秋了,凉快点了,猿们还是老老实实干活儿吧^_^

发布于 2017-08-11