高并发问题抛去架构层面的问题,落实到代码层面就是多线程的问题。多线程的问题主要是线程安全的问题(其他还有活跃性问题,性能问题等)。
那什么是线程安全?下面这个定义来自《Java并发编程实战》,这本书强烈推荐,是几个Java语言的作者合写的,都是并发编程方面的大神。
线程安全指的是:当多个线程访问某个类时,这个类始终都能表现出正确的行为。
正确指的是“所见即所知”,程序执行的结果和你所预想的结果一致。
理解线程安全的概念很重要,所谓线程安全问题,就是处理对象状态的问题。如果要处理的对象是无状态的(不变性),或者可以避免多个线程共享的(线程封闭),那么我们可以放心,这个对象可能是线程安全的。当无法避免,必须要共享这个对象状态给多线程访问时,这时候才用到线程同步的一系列技术。
这个理解放大到架构层面,我们来设计业务层代码时,业务层最好做到无状态,这样就业务层就具备了可伸缩性,可以通过横向扩展平滑应对高并发。
所以我们处理线程安全可以有几个层次:
1. 能否做成无状态的不变对象。无状态是最安全的。
2. 能否线程封闭
3. 采用何种同步技术
我理解为能够“逃避”多线程问题,能逃则逃,实在不行了再来处理。
了解了线程封闭的背景,来说说线程封闭的具体技术和思路
1. 栈封闭
2. ThreadLocal
3. 程序控制线程封闭
栈封闭说白了就是多使用局部变量。理解Java运行时模型的同学都知道局部变量的引用是保持在线程栈中的,只对当前线程可见,其他线程不可见。所以局部变量是线程安全的。
ThreadLocal机制本质上是程序控制线程封闭,只不过是Java本身帮忙处理了。来看Java的Thread类和ThreadLocal类
1. Thread线程类维护了一个ThreadLocalMap的实例变量
2. ThreadLocalMap就是一个Map结构
3. ThreadLocal的set方法取到当前线程,拿到当前线程的threadLocalMap对象,然后把ThreadLocal对象作为key,把要放入的值作为value,放到Map
4. ThreadLocal的get方法取到当前线程,拿到当前线程的threadLocalMap对象,然后把ThreadLocal对象作为key,拿到对应的value.
public class Thread implements Runnable {
ThreadLocal.ThreadLocalMap threadLocals = null;
}
public class ThreadLocal {
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null)
return (T)e.value;
}
return setInitialValue();
}
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
}
使用ThreadLocal前提是给每个ThreadLocal保存一个单独的对象,这个对象不能是在多个ThreadLocal共享的,否则这个对象也是线程不安全的。
Structs2就用了ThreadLocal来保存每个请求的数据,用了线程封闭的思想。但是ThreadLocal的缺点也显而易见,必须保存多个副本,采用空间换取效率。
程序控制线程封闭,这个不是一种具体的技术,而是一种设计思路,从设计上把处理一个对象状态的代码都放到一个线程中去,从而避免线程安全的问题。
有很多这样的实例,Netty5的EventLoop就采用这样的设计,我们的游戏后台处理用户请求是也采用了这种设计。
具体的思路是这样的:
1. 把和用户状态相关的代码放到一个队列中去,由一个线程处理
2. 考虑是否隔离用户之间的状态,即一个用户使用一个队列,还是多个用户使用一个队列
拿Netty举例,EventLoop被设计成了一个线程的线程池。我们知道线程池的组成是工作线程 + 任务队列。EventLoop的工作线程只有一个。
用户请求过来后被随机放到一个EventLoop去,也就是放到EventLoop线程池的任务队列,由一个线程来处理。并且处理用户请求的代码都使用Pipeline职责链封装好了,一个Pipeline交给一个线程来处理,从而保证了跟同一个用户的状态被封闭到了一个线程中去。
更多Netty EventLoop相关的内容看这篇 Netty5源码分析(二) -- 线程模型分析
这里有个问题也显而易见,就是如果把多个用户都放到一个队列,交给一个线程处理,那么前一个用户的处理速度会影响到后一个用户被处理的时间。
我们的游戏服务器的设计采用了一个用户一个任务队列的方式,处理任务的代码被做成了Runnable,这样多个Runnable可以交给一个线程池执行,从而多个用户可以同时被处理,而同一个用户的状态处理被封闭到了唯一的一个任务队列中,互不干扰。
但是也有问题,即线程池内的工作线程和任务队列是有界的,所以单个线程处理的时间必须要快,否则大量请求被积压在任务队列来不及处理,一旦任务队列也满了,那么后续的请求都进不来了。
如果使用无界的任务队列,所有请求能进来,但是问题是高并发情况下大量请求过来,会把系统内存撑爆,倒置OOM。
所以一个常用的设计思路如下:
1. 采用有界的任务队列和不限个数的工作线程,这样可以平滑地处理高并发,不至于内存被撑爆
2. 单个线程请求时间必须要快,尽量不超过100ms
3. 如果单个线程处理的时间由于任务太大必须耗时,那么把任务拆个小任务来多次执行
4. 拆成小任务还是慢,那么把同步操作变成异步操作,即方法执行后立即返回,不要等待结果。由另一个线程异步地处理线程,比如采用单独的线程定时检查处理状态,或者采用异步回调的方式