并发是提高系统吞吐量的关键手段,但是构建高性能的并发系统并非易事。通过利用经典的模式可以使我们站在巨人的肩上,Half-Sync/Half-Async和Leader-Follower正是两种最经典的并发模式。
在实际中模式学习的难点往往在于好像看懂了,但是却总不能在正确的场景中使用。为了使大家能更好的理解和在实际中正确的使用这两种模式,下面将通过构建高性能网络通信的场景(Java NIO)来对模式进行一一的解析。
Java NIO简介
在NIO出现前,Java IO的各种流是阻塞的。这意味着,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。Java NIO的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。线程通常将非阻塞IO的空闲时间用于在其它通道上执行IO操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel),NIO可以减少线程资源消耗支持更多的并发连接。
NIO中的Seletor就是利用单一线程管理多个并发连接/通道(channel)的关键,如果你的应用打开了多个通道,但每个连接的流量都很低,使用Selector就会很高效。
使用Selector的典型代码通常如下:
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(8000));
Selector selector = Selector.open();
serverSocketChannel.configureBlocking(false);
//将Selector注册到ServerSocket
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
//通过selector来轮询channel,接收事件
while (true) {
int readyChannels = selector.select();
if (readyChannels == 0) continue;
Set selectedKeys = selector.selectedKeys();
Iterator keyIterator = selectedKeys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
SocketChannel client = serverSocketChannel.accept();
client.write(ByteBuffer.wrap("Hello\n".getBytes()));
client.close();
} else if (key.isConnectable()) {
// a connection was established
} else if (key.isReadable()) {
// a channel is ready for reading
} else if (key.isWritable()) {
// a channel is ready for writing
}
keyIterator.remove();
}
}
为了简单以上只处理了Accept事件。
可以看到以上通过selector来检查事件的轮训过程必须在线程安全的状态下进行,试想如果是在多线程的情况下,可能会发生多个线程同时处理一个channel的同一事件的情况。
然而,完全的单线程环境会大大降低服务程序的吞吐量,所以我们可以采用以下模式来提高吞吐量。
Half-Sync/Half-Async
为了提高系统的吞吐量,请求处理分为两层同步层和异步层。同步层通过Selector轮询channel获取的事件,而事件的处理则是通过其它工作线程来异步完成。两层之间通过一个队列来协调,这样事件的处理过程就不会阻塞轮询线程,可以让不同channel的事件获得更快的响应,不同事件的处理可以同步进行,大大提高了系统的吞吐量。
代码修改的如下。
ExecutorService threadPool = Executors.newFixedThreadPool(NUM_OF_THREADS);
while (true) {
int readyChannels = selector.select();
if (readyChannels == 0) continue;
Set selectedKeys = selector.selectedKeys();
Iterator keyIterator = selectedKeys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
SocketChannel client = serverSocketChannel.accept();
threadPool.execute(new Runnable() {
@Override
public void run() {
client.write(ByteBuffer.wrap("Hello\n".getBytes()));
client.close();
}
});
...
为了提高性能程序进一步使用了线程池来做异步事件处理。
你可能会在想对比原理图协调同步层和异步层之间的队列在哪呢?其实这个队列已经包含在ExecutorService的线程池官方实现中了,以下是newFixedThreadPool的官方实现代码:
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue());
}
通过分析你会发现Half-Sync/Half-Async模式中,需要在同步和异步模式中切换,需要将数据通过队列在从网络I/O线程传递到工作线程,这里通常会涉及动态的内存分配,以及需要进行锁同步的写入及读取删除等队列操作。这些都会对性能有一定的影响。同时,数据在不同线程中传递或被访问也破坏了CPU的缓存机制来从而也影响了性能。
Leader-Follower
Half-Sync/Half-Async,由于事件的接收和处理过程中存在线程的切换,从而导致了上面提及的一些影响性能的问题。下面要介绍的Leader-Follower就是针对如何避免以上线程切换问题而设计的一种并发模式。
Leader-Follower同样是基于一组预启动的线程来实现,这组线程会选出一个线程作为Leader。Leader线程将准备接收I/O事件(如:上面提及的通过selector轮询的方式),当Leader线程接收到事件后,就会:
以上方式就实现了不同事件的并行处理。
代码如下:
class WorkerThread implements Runnable {
private int workID;
private Lock leaderToken;
private Selector selector;
private ServerSocketChannel serverSocketChannel;
public WorkerThread(int workID, Lock leaderToken, Selector selector, ServerSocketChannel serverSocketChannel) {
this.leaderToken = leaderToken;
this.serverSocketChannel = serverSocketChannel;
this.workID = workID;
this.selector = selector;
}
@Override
public void run() {
while (true) {
leaderToken.lock(); // 等待获取Leader token,获取后成为leader线程
System.out.println("work " + this.workID + " got leader token.");
try {
out: while (true) { // check the ready channel
int readyChannels;
readyChannels = selector.select();
if (readyChannels == 0)
continue;
Set selectedKeys = selector.selectedKeys();
Iterator keyIterator = selectedKeys.iterator();
while (keyIterator.hasNext()) {// handle the event
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
keyIterator.remove();
SocketChannel client = serverSocketChannel.accept();
leaderToken.unlock(); // 接收完事件后,释放Leader Token,让其它线程成为Leader
// 继续处理事件
System.out.println("work " + this.workID + " released leader token.");
client.write(ByteBuffer.wrap("Hello\n".getBytes()));
client.close();
break out;
}
}
}
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
if (leaderToken.tryLock()) {
leaderToken.unlock();
}
}
}
}
}
public class LeaderFollowerServer {
final static int NUM_OF_THREAD = 6;
public void start() throws Exception {
Lock leaderToken = new ReentrantLock();
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(8000));
Selector selector = Selector.open();
serverSocketChannel.configureBlocking(false);
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
for (int i = 0; i < NUM_OF_THREAD; i++) {
new Thread(new WorkerThread(i, leaderToken, selector, serverSocketChannel)).start();
}
}
}
代码中通过一个同步量(lock)的获取与释放来实现Leader的切换。
与Half-Sync/Half-Async相比Leader-Follower模式具有以下优点:
提高了CPU缓存亲和性,避免了不同线程间数据共享。
避免了通过在不同线程间交换数据,减少了锁同步操作。
由于接收事件到处理事件间没有切换过程,便于提高事件处理的实时性。
完整代码参见:
https://github.com/chaocai2001/pattern_oriented_software_architecture/tree/master/concurrency_patterns
欢迎关注我的Go语言教程(http://gk.link/a/102s8),教程中在构建大规模高可用性系统一节也向大家介绍了两种经典的架构模式(pipe-filter 和 micro-kernel)。