附录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

最低0.47元/天 解锁文章
629

被折叠的 条评论
为什么被折叠?



