火车票预订系统的特性决定了它必须要满足高吞吐率高并发的要求,用户瞬时请求量可能非常大并且要求系统响应速度在可接受范围内。
如果为每个客户请求分配一个线程,只要请求的到达速率不超出服务器的请求处理能力,那么这种方法可以同时带来更快的响应性和更高的吞吐率。这种将每个任务放在各自的线程中执行,问题在于资源管理的复杂性。而线程池简化了对线程的管理工作。线程池是用于管理一组同构工作线程的资源池。工作者线程Worker Thread从工作队列中获取一个任务执行,然后返回线程池并等待下一个任务。“在线程池中执行任务”比“为每个任务分配一个线程”更具优势,通过重用现有的线程而不是创建新线程,可以在处理多个请求时分摊在线程创建和销毁过程中产生的巨大开销。另外当请求到达时,工作线程通常已经存在,因此不会由于等待创建线程而延迟任务的执行,从而提高了响应性。
那么该选择哪种线程池呢?newFixedThreadPool和newSingleThreadExecutor在默认情况下将使用一个无界的LinkedBlockingQueue。如果所有工作线程都处于忙碌状态,那么任务将在队列中等候。如果任务持续快速的到达,并且超过了线程池处理它们的速度,那么队列将无限制的增加。这显然不适合火车票预订系统,会造成大量用户提交请求后系统长时间没有响应的现象。一种更稳妥的资源管理策略是使用有界队列,例如ArrayBlockingQueue、有界的LinkedBlockingQueue、PriorityBlockingQueue。有界队列有助于避免资源耗尽的情况发生。当使用有界队列时,队列的大小与线程池的大小必须一起调节。如果线程池较小而队列较大,那么有助于减少内存使用量,降低CPU的使用率,同时还可以减少上下文切换,但付出的代价是可能会限制吞吐量。对于非常大的或者无界的线程池,可以通过使用SynchronousQueue来避免任务排队,以及直接将任务从生产者移交给工作者线程。只有当线程池是无界的或者可以拒绝任务时,SynchronousQueue才有实际价值。在newCachedThreadPool工厂方法中就是用了SynchronousQueue。这种尽可能早地拒绝的方式是中国老百姓还能接受的方式,这次请求不行系统马上告知失败,然后可以多尝试几次,只要有一次能流畅的操作就ok。还可以让应用分业务做服务拒绝,业务分功能点做服务拒绝。另外对于铁路系统来说,多加些机器也不是什么大问题,预算是宽裕的,铁老大不差钱。
针对火车票预定系统的特点个人认为可以创建线程池:ExecutorService exec = Executors.newCachedThreadPool();或者不采用这种默认的执行策略,而是通过ThreadPoolExecutor的构造函数来实例化一个对象,并根据自己的需求来定制。
public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,BolckingQueue<Runnable> workQueue,ThreadFactory threadFactory,RejectedExecutionHandler handler){...}
线程池的大小的设定:对于计算密集型的任务,在拥有n个处理器的系统上,当线程池的大小为n+1时,通常能实现最优的利用率。对于包含I/O操作或者其他阻塞操作的任务,由于线程并不会一直执行,因此线程池的规模应该更大。
线程池的大小=CPU数量*CPU利用率*(任务的等待时间+任务的计算时间)/任务的计算时间。
其中任务的等待时间和计算时间的估算不需要很精确,并且可以通过一些分析或监控工具来获得。还可以通过另一种方法来调整线程池的大小:在某个负载基准下,分别设置不同大小的线程池来运行应用程序,并观察CPU利用路的水平。
对于执行时间较长的任务,可以通过限定任务等待资源的时间,而不要无限制的等待的方式来缓解。如果等待超时,那么可以把任务标识为失败,然后中止任务或者将任务重新放回队列以便随后执行。这样,无论任务的最终结果是否成功,这种方法总能确保任务总能继续执行下去,并将线程释放出来以执行一些能更快完成的任务。
饱和策略的“调用者运行caller-runs”策略实现了一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退给调用者,从而降低新任务的流量。它不会在线程池的某个线程中执行新提交的任务,而是在一个调用了execute的线程中执行该任务。当线程池中的所有线程都被占用,并且工作队列被填满后,下一个任务会在调用execute时在主线程中执行。由于执行任务需要一定时间,因此主线程至少在一段时间内不能提交任何任务,从而使得工作者线程有时间来处理完正在执行的任务。在这期间,住线程不会调用accept,因此到达的线程会被保存在TCP层的队列中而不是在应用程序的队列中。如果持续过载,那么TCP层将最终发现它的请求队列被填满,因此同样会开始抛弃请求。当服务器过载时,这种过载情况会逐渐向外蔓延----从线程池到工作队列到应用程序再到TCP层,最终达到客户端,导致服务器在高负载下实现一种平缓的性能降低。
上面说了对这些参数进行配置时需要考虑的原则,靠谱的方式是进行性能测试,多次尝试找到合适的配置参数,经过负载、稳定性、压力的全面测试,从而确保在线程数量达到最大限制时,程序也不会耗尽资源。
另外,对于在多处理器系统上被频繁读取的数据结构,读写锁ReentrantReadWriteLock能够提高性能。
如果需要执行不同类别的任务,并且他们之间的行为相差很大,那么应该考虑使用多个线程池,从而使每个线程池可以根据各自的工作负载来调整。
另外可以基于信号量做流控,对操作控制最大线程数量,这样当系统变慢时新的线程就不会请求直接返回。