我想大多数人在学习多线程时都会对此问题有所顾虑,尽管多线程的概念不难理解,那我们什么时候该用它呢?在大多数情况下,我们写了程序,发现有时必须使用多线程才能得到理想的运行结果,于是我们按照资料调用相关的线程类库或API改善程序,并使其正常运行;但是,到底存不存在一种判断依据,能够明确的指导我们正确地使用多线程机制来解决问题呢?笔者对此进行了一番思考,在此说说我的想法以供参考。
在开始之前,先引入几个问题,这些问题最终都会在这篇文章里找到答案。
问题情景[0]:设计一个简单的UI:包括一个文本标签和一个按钮,在点击按钮时文本显示由0~10的增长,每秒增长量为1。
问题情景[1]:某同学编写的坦克大战程序中,每一个坦克和子弹均使用一个独立的线程,这是否合理?(当然不合理。。。)如果是你,你会怎么编写这个程序?
笔者认为,多线程的使用离不开“阻塞”这个概念,不过,我想先对这个概念加以扩充,首先先来回想一下阻塞概念原本的意思,简单的说,就是程序运行到某些函数或过程后等待某些事件发生而暂时停止CPU占用的情况;也就是说,是一种CPU闲等状态,不过有时我们使用多线程并不一定是保持闲等时的程序响应,例如在追求高性能的程序中,某条线程在进行高强度的运算,此时若对运算性能不满意,我们也许会再启动若干条运算线程(当然,是在CPU有运算余力的情况下),此时,高强度运算应该归为一种“忙等”状态。
说到这,多线程归根究底是为了解决"等"的问题,那我们这样定义一个阻塞过程:程序运行该过程所消耗的时间有可能在运行上下文间产生明显的卡顿;这里使用“可能”是因为有些情况下,诸如Socket通信,如果数据源源不断的进入,那么阻塞的时间可能非常小,但我们还是使用了一条线程(nio另说)来处理它,因为我们无法保证数据到来的持续性和有效性;"卡顿"带有主观臆想,也就是说是使用者(人或一些自动化程序)不可接受的。
接下来,对什么时候使用多线程做一个回答:编写程序过程中需要使用某些阻塞过程时,我们才使用多线程,或者更进一步讲,使用多线程的目的是对阻塞过程中的实际阻塞的抽象提取。前半句话应该很好理解,而后面的一句虽然不太好懂,不过它对一个程序应具有的合理线程数量进行了阐释(这点接下来解释)。
好了,接下来我们回顾一些两个问题,并对它们做出解答:
问题情景[0]:
为了方便表达,笔者在此采用伪Java代码来阐释解答过程。首先我们有一个Label textShower用于显示文本,Button textChanger作为点击的按钮
这个问题是笔者还是一名小菜时遇到的,当时笔者是这么写的:
- public class MyFrame
- {
- Label textShower;
- Button textChanger;
- public MyFrame//实例化等省略
- {
- textChanger.setOnClickListener(new OnClickListener(){
- public void onClick(MouseEvent e){
- for(int i = 1;i <= 10;i++){
- textShower.setText(i+"");//设置文字
- Thread.sleep(1000);//等待一秒
- }
- }
- });
- }
- }
程序的执行结果是点击后10秒没有响应,然后数值被设定为10;现在知道了,是由于AWT消息线程同时负责着图像的绘制刷新操作,而Thread.sleep属于之前的阻塞过程,导致画面停止响应。
当时老师是这么教给我的:
- public class MyFrame
- {
- Label textShower;
- Button textChanger;
- public MyFrame//实例化等省略
- {
- textChanger.setOnClickListener(new OnClickListener(){
- public void onClick(MouseEvent e){
- new Thread(){
- public void run()
- {
- for(int i = 0;i < 10;i++){
- textShower.setText(i+"");//设置文字
- Thread.sleep(1000);//等待一秒
- }
- }
- }.start();
- }
- });
- }
- }
当然,这样确实能够满足题目要求,我也因此开心了一阵,不过不久我就有了新的问题:每按一次按钮产生一个线程是否合理呢?如果这样的文本组合再多几个,我也要创建更多的线程吗?要是使用者是个熊孩子来这里一通狂按这程序还受得了么....
后来,在面向对象思想深入人心后,稍微懂面向对象的人都会知道使用抽象来简化程序,只不过在上面的问题中,我们需要抽象的不是具体的实体,而是“实际阻塞”这种抽象概念。
在上面的代码中,笔者写的第一个onClick函数属于一个阻塞过程,其中sleep属于“实际阻塞”。
而改版的代码中,只是使用多线程将原本的阻塞过程变为了非阻塞过程,实际上是使用了一个独立的线程将整个阻塞过程包含在内,并没有做任何的抽象。
问题情景[1]:在这个问题中,将主要讨论实际阻塞的抽象和合理线程数量的问题。
这个情景是不久前一位网友问我的,他的毕业设计是编写一个坦克大战的游戏,在编的差不多的时候,突然想到每一辆坦克、每一发子弹都是用单独的线程不是很合理,问我如何改进。用这个例子说明实际阻塞的抽象再合适不过了,我们先看看他写的代码片段:
坦克类:
- public class Tank extends Thread{
- float x;//这里以横向移动为例子,只写一个属性
- float speed = 1f;
- public void run()
- {
- drawtank();//清除上一次的绘制,根据横坐标x画一个坦克
- x+=speed;
- Thread.sleep(17);//约合一秒60次
- }
- }
子弹类:
- public class Bullet extends Thread{
- float x;//这里以横向移动为例子,只写一个属性
- float speed = 10f;
- public void run()
- {
- drawbullet();//清除上一次的绘制,根据横坐标x画一个子弹
- x+=speed;
- Thread.sleep(17);//约合一秒60次
- }
- }
其实这样异步的绘制会使画面产生明显的抖动,而且用于同步的逻辑也十分复杂,并不是一个好的方案。
其实上面两个类中的run方法中,只有sleep属于实际阻塞,也就是说是可以被抽象出来的,我们只要一个线程,每过17毫秒执行一些列非阻塞过程即可。
上述过程中,绘制及坐标的运算属于非阻塞过程,我们将其抽象为一个接口:
- public interface Drawable
- {
- public void draw();
- }
之后我们书写抽象实际阻塞的线程类:
- public class BlockThread extends Thread
- {
- Collection<Drawable> c = new Collection<Drawable>();
- public void run()
- {
- for(Drawable d:c)
- {
- d.draw();
- }
- Thread.sleep(17);
- }
- //封装对成员c的同步CRUD不赘述
- public void addDrawable(Drawable d);
- public void removeDrawable(Drawable d);
- ...
- }
最后,坦克和子弹的改动:
坦克类:
- public class Tank implements Drawable{
- float x;//这里以横向移动为例子,只写一个属性
- float speed = 1f;
- @Override
- public void draw()
- {
- drawtank();//清除上一次的绘制,根据横坐标x画一个坦克
- x+=speed;
- }
- }
子弹类:
- public class Bullet implements Drawable{
- float x;//这里以横向移动为例子,只写一个属性
- float speed = 10f;
- @Override
- public void draw()
- {
- drawbullet();//清除上一次的绘制,根据横坐标x画一个子弹
- x+=speed;
- }
- }
我们可以发现:原有的实际阻塞过程已经被抽象到一个线程之中,而非阻塞过程,诸如绘制和坐标运算依然作为方法保留到对应类中,这样,无论有多少坦克和炮弹,只要非阻塞过程的运算压总和力不至于逼近阻塞的程度,使用一个线程即可完成所有工作。
而且,如果想要添加游戏元素,例如其他类型的子弹,只需要实现Drawable接口即可。
写到这,UI的问题也就解决了,诸如sleep这样纯粹延时的阻塞非常容易抽象,我们可以如法炮制,使用一个线程解决所有的数值延时自增的问题。但并不是所有实际阻塞都易于抽象,如socket.read(byte[] b);这样的方法显然没有抽象的余地,因此才引出后来的nio方案。
最后,我们对什么时候使用多线程,以及使用线程的数量做一个总结:在编写程序时,遇到了阻塞过程而不想使整个程序停止响应时,应使用多线程;一个程序的合理线程数量取决于对实际阻塞的抽象程度。