【Code】《代码整洁之道》笔记-附录A-并发编程II

附录A 并发编程II

本附录扩充了第13章的内容,由一组相互独立的主题组成,你可以按随意顺序阅读。为了实现这样的阅读方式,节与节之间存在一些重复内容。

A.1 客户端/服务器的例子

想象一个简单的客户端/服务器应用程序。服务器在一个套接字上等待接受来自客户端的连接请求。客户端连接到服务器并发送请求。

A.1.1 服务器

下面是服务器应用程序的简化版本代码。在A.10节中有完整的代码。

ServerSocket serverSocket = new ServerSocket(8009);

while (keepProcessing) {
  try {
    Socket socket = serverSocket.accept();
    process(socket);
  } catch (Exception e) {
    handle(e);
  }
}

这个简单的应用等待连接请求,处理接收到的新消息,再等待下一个客户端请求。下面是连接到服务器的客户端代码:

private void connectSendReceive(int i) {
  try {
    Socket socket = new Socket("localhost", PORT);
    MessageUtils.sendMessage(socket, Integer.toString(i));
    MessageUtils.getMessage(socket);
    socket.close();
  } catch (Exception e) {
    e.printStackTrace();
  }
}

这对客户端/服务器程序运行得如何呢?怎样才能正式地描述其性能?下面是断言其性能“可接受”的测试:

@Test(timeout = 10000)
public void shouldRunInUnder10Seconds() throws Exception {
  Thread[] threads = createThreads();
  startAllThreads(threads);
  waitForAllThreadsToFinish(threads);
}

为了让例子够简单,设置过程被忽略了(见代码清单A-4)。测试断言程序应该在10 000毫秒内完成。

这是个验证系统吞吐量的典型例子。系统应该在10秒内完成一组客户端请求。只要服务器能在时限内处理每个客户端请求,测试就通过了。

如果测试失败会怎样?缺少了某些事件轮询机制,在单个线程上也没什么可让代码更快的手段。使用多线程能解决问题吗?可能会,我们先得了解什么地方耗费时间。下面是两种可能:

  • I/O——使用套接字、连接到数据库、等待虚拟内存交换等;

  • 处理器——数值计算、正则表达式处理、垃圾回收等。

以上在系统中都会部分存在,但对于特定的操作,其中之一会起主导作用。如果代码运行速度主要与处理器有关,增加处理器硬件就能提升吞吐量,从而通过测试。但CPU运算周期是有上限的,因此,只是增加线程的话并不会提升受处理器限制的代码的速度。

另外,如果吞吐量与I/O有关,则并发编程能提升运行效率。当系统的某个部分在等待I/O,另一部分就可以利用等待的时间处理其他事务,从而更有效地利用CPU能力。

A.1.2 添加线程代码

假定性能测试失败了。如何才能提高吞吐量、通过性能测试呢?如果服务器的process方法与I/O有关,就有一个办法让服务器利用线程(只需要修改process方法):

void process(final Socket socket) {
  if (socket == null)
    return;

  Runnable clientHandler = new Runnable() {
    public void run() {
      try {
        String message = MessageUtils.getMessage(socket);
        MessageUtils.sendMessage(socket, "Processed: " + message);
        closeIgnoringException(socket);
      } catch (Exception e) {
        e.printStackTrace();
      }
    }
  };

  Thread clientConnection = new Thread(clientHandler);
  clientConnection.start();
}

假设修改后测试通过了。代码是否完整、正确了呢?

A.1.3 观察服务器端

修改了的服务器成功通过测试,只花费了一秒多时间。不幸的是,这种解决手段有点一厢情愿,而且导致了新问题产生。

服务器应该创建多少个线程?代码没有设置上限,所以我们很有可能达到Java虚拟机(JVM)的限制。对许多简单系统来说这无所谓。但如果系统要支持公众网络上的众多用户呢?如果有太多用户同时连接,系统就有可能挂掉。

不过先把性能问题放到一边吧。这种手段还有整洁性和结构上的问题。服务器代码有多少种权责呢?如下所列:

  • 套接字连接管理;

  • 客户端处理;

  • 线程策略;

  • 服务器关闭策略。

这些权责不幸全在process函数中。而且,代码跨越多个抽象层级。所以,即便process函数这么短小,也还是需要再加以切分。

服务器有多个修改的原因,所以它违反了单一权责原则。要保持并发系统整洁,应该将线程管理代码约束于少数几处控制良好的地方。而且,管理线程的代码只应该做管理线程的事。为什么?即便无须同时考虑其他非多线程代码,跟踪并发问题也已经足够困难了。

如果为上述每个权责(包括线程管理权责在内)创建单独的类,当改动线程管理策略时,就会对整个代码产生较小影响,不至于污染其他权责。这样一来,也能在不担心线程问题的前提下测试所有其他权责。下面是修改过的版本:

public void run() {
  while (keepProcessing) {
    try {
      ClientConnection clientConnection = connectionManager.awaitClient();
      ClientRequestProcessor requestProcessor 
        = new ClientRequestProcessor(clientConnection);
      clientScheduler.schedule(requestProcessor);
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
  connectionManager.shutdown();
}

所有与线程相关的东西都放到了clientScheduler里面。如果出现并发问题,只要看这个地方就可以了:

public interface ClientScheduler {
  void schedule(ClientRequestProcessor requestProcessor);
}

并发策略易于实现:

public class ThreadPerRequestScheduler implements ClientScheduler {
  public void schedule(final ClientRequestProcessor requestProcessor) {
    Runnable runnable = new Runnable() {
      public void run() {
          requestProcessor.process();
      }
    };

    Thread thread = new Thread(runnable);
    thread.start();
  }
}

把所有线程管理隔离到一个位置,修改控制线程的方式就容易多了。例如,移植到Java 5 Executor框架就只需要编写一个新类并插进来即可(如代码清单A-1所示)。

代码清单A-1 ExecutorClientScheduler.java

import java.util.concurrent.Executor;
import java.util.concurrent.Executors;

public class ExecutorClientScheduler implements ClientScheduler {
  Executor executor;

  public ExecutorClientScheduler(int availableThreads) {
    executor = Executors.newFixedThreadPool(availableThreads);
  }

  public void schedule(final ClientRequestProcessor requestProcessor) {
    Runnable runnable = new Runnable() {
      public void run() {
        requestProcessor.process();
      }
    };
    executor.execute(runnable);
  }
}

A.1.4 小结

本例介绍的并发编程,演示了一种提高系统吞吐量的方法,以及一种通过测试框架验证吞吐量的方法。将全部并发代码放到少数类中,是应用单一权责原则的范例。对于并发编程,因其复杂性,这一点尤其重要。

A.2 执行的可能路径

复查没有循环或条件分支的单行Java方法incrementValue

public class IdGenerator {
  int lastIdUsed;

  public int incrementValue() {
    return ++lastIdUsed;
  }
}

忽略整数溢出的情形,假定只有单个线程能访问IdGenerator的单个实体。这种情况下,只有一种执行路径和一个确定的结果:

  • 返回值等于lastIdUsed的值,两者都比调用方法前大1。

如果使用两个线程、不修改方法的话会发生什么?如果每个线程都调用一次incrementValue,可能得到什么结果呢?有多少种可能执行路径?首先来看结果(假定lastIdUsed初始值为93):

  • 线程1得到94,线程2得到95,lastIdUsed为95;

  • 线程1得到95,线程2得到94,lastIdUsed为95;

  • 线程1得到94,线程2得到94,lastIdUsed为94。

最后一个结果尽管令人吃惊,但还是有可能出现的。要想明白为何可能出现这些结果,就需要理解可能执行路径的数量以及Java虚拟机是如何执行这些路径的。

A.2.1 路径数量

为了算出可能执行路径的数量,我们从生成的字节码开始研究。那行Java代码(return ++lastIdUsed;)变成了8个字节码指令。两个线程有可能交错执行这8个指令,就像庄家在洗牌时交错牌张一样。即便每只手上只有8张牌,洗牌得到的结果数量也很可观。

对于指令系列中有N个指令和T个线程、没有循环或条件分支的简单情况,总的可能执行路径数量等于 (NT)!/N!^T

以下摘自鲍勃大叔给Brett的一封电子邮件。

对于N步指令和T个线程,总共有T*N个步骤。在执行每步指令之前,会有在T个线程中选择其一的环境开关。因而每条路径都能以一个数字字符串的形式来表示该环境开关。对于步骤A、B及线程1和2,可能有6条可能路径:1122、1212、1221、2112、2121和2211。或者以指令步骤表示为A1B1A2B2、A1A2B1B2、A1A2B2B1、A2A1B1B2、A2A1B2B1及A2B2A1B1。对于3个线程,执行序列就是112233、112323、113223、113232、112233、121233、121323、121332、123132、123123……

这些字符串的特征之一是每个T总会出现N次。所以字符串111111是无效的,因为里面有6个1,而2和3则未出现过。

所以要排列组合N1、N2……直至NT。这其实就是N * T对应N*T的排列,即(N*T)!,但要剔除重复的情形。所以,巧妙之处就在于计算重复次数并从(N*T)!中剔除掉。

对于两步指令和两个线程,有多少重复呢?每个四位数字符串中都有两个1和两个2。每个这种配对都可以在不影响字符串意义的前提下调换。可以同时调换全部1和2,也可以都不调换。所以每个字符串就有四种同构形态,即存在3次重复。所以四分之三的路径是重复的,而四分之一的排列则不重复。4!×0.25=6。这样计算看来可行。

有多少重复呢?对N =1且T =2的情形,我可以调换1,调换2,或两者都调换。对N =2且T =3的情形,我可以调换1、2、3,1和2,1和3,或2和3。调换只是N的排列组合罢了。设有N的P种排列组合。排列组合的方式总共有P**T种。

所以可能的同构形态数量为N!**T。路径的数量就是(T*N)!/(N!**T)。对T = 2且N = 2的情况,结果就是6(即24/4)。

对N = 2且T = 3的情况,结果是720/8=90。

对N = 3且T = 3的情况,结果是9!/63=168
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值