什么是协程
计算机有进程,线程和协程。前两者大家都知道,很常见的玩意。而协程,则是基于线程之上的,自主开辟的异步任务,很多人更喜欢叫它们纤程(Fiber),或者绿色线程(GreenThread)。
协程的特点:
线程的切换由操作系统负责调度,协程由用户自己进行调度,因此减少了上下文切换。
线程的默认Stack大小是1M,而协程更轻量,接近1K。因此可以在相同的内存中开启更多的协程。
由于在同一个线程上,因此可以避免竞争关系而使用锁。
操作系统产生一个进程,进程再产生若干个线程并行的处理逻辑,线程的切换由操作系统负责调度。传统语言C++ Java等线程其实与操作系统线程是1:1的关系,每个线程都有自己的Stack,Java在64位系统默认Stack大小是1024KB,所以指望一个进程开启上万个线程是不现实的。但是实际上我们也不会这么干,因为起这么多线程并不能充分的利用CPU,大部分线程处于等待状态,CPU也没有这么核让线程使用。所以一般线程数目都是CPU的核数。
传统的J2EE系统都是基于每个请求占用一个线程去完成完整的业务逻辑(包括事务)。所以系统的吞吐能力取决于每个线程的操作耗时。如果遇到很耗时的I/O行为,则整个系统的吞吐立刻下降,比如JDBC是同步阻塞的,这也是为什么很多人都说数据库是瓶颈的原因。这里的耗时其实是让CPU一直在等待I/O返回,说白了线程根本没有利用CPU去做运算,而是处于空转状态。暴殄天物啊。另外过多的线程,也会带来更多的ContextSwitch开销。
协程的本质上其实还是和上面的方法一样,只不过他的核心点在于调度那块由他来负责解决,遇到阻塞操作,立刻yield掉,并且记录当前栈上的数据,阻塞完后立刻再找一个线程恢复栈并把阻塞的结果放到这个线程上去跑,这样看上去好像跟写同步代码没有任何差别,这整个流程可以称为coroutine,而跑在由coroutine负责调度的线程称为Fiber。比如Golang里的 go关键字其实就是负责开启一个Fiber,让func逻辑跑在上面。而这一切都是发生的用户态上,没有发生在内核态上,也就是说没有ContextSwitch上的开销。
java 携程框架Quasar
Quasar里的Fiber其实是一个continuation,他可以被Quasar定义的scheduler调度,一个continuation记录着运行实例的状态,而且会被随时中断,并且也会随后在他被中断的地方恢复。Quasar其实是通过修改bytecode来达到这个目的,所以运行Quasar程序的时候,你需要先通过java-agent在运行时修改你的代码,当然也可以在编译期间这么干。golang的内置了自己的调度器,Quasar则默认使用ForkJoinPool这个JDK7以后才有的,具有work-stealing功能的线程池来当调度器。work-stealing非常重要,因为你不清楚哪个Fiber会先执行完,而work-stealing可以动态的从其他的等等队列偷一个context过来,这样可以最大化使用CPU资源。
那这里你会问了,Quasar怎么知道修改哪些字节码呢,其实也很简单,Quasar会通过java-agent在运行时扫描哪些方法是可以中断的,同时会在方法被调用前和调度后的方法内插入一些continuation逻辑,如果你在方法上定义了@Suspendable注解,那Quasar会对调用该注解的方法做类似下面的事情。
这里假设你在方法f上定义了@Suspendable,同时去调用了有同样注解的方法g,那么所有调用f的方法会插入一些字节码,这些字节码的逻辑就是记录当前Fiber栈上的状态,以便在未来可以动态的恢复。(Fiber类似线程也有自己的栈)。在suspendable方法链内Fiber的父类会调用Fiber.park,这样会抛出SuspendExecution异常,从而来停止线程的运行,好让Quasar的调度器执行调度。这里的SuspendExecution会被Fiber自己捕获,业务层面上不应该捕获到。如果Fiber被唤醒了(调度器层面会去调用Fiber.unpark),那么f会在被中断的地方重新被调用(这里Fiber会知道自己在哪里被中断),同时会把g的调用结果(g会return结果)插入到f的恢复点,这样看上去就好像g的return是f的local variables了,从而避免了callback嵌套。
Quasar VS Golang
java
public class xiecheng{
private static Random random = new Random();
private static final int NUMBER_COUNT = 1000;
private static final int RUNS = 4;
private static final int BUFFER = 1000; // = 0 unbufferd, > 0 buffered ; < 0 unlimited
private static void numberSort() {
int[] nums = new int[NUMBER_COUNT];
for (int i = 0; i < NUMBER_COUNT; i++)
nums[i] = random.nextInt(NUMBER_COUNT);
Arrays.sort(nums);
}
static void skynet(Channel<Long> c, long num, int size, int div) throws SuspendExecution, InterruptedException {
if (size == 1) {
c.send(num);
return;
}
//加入排序逻辑
numberSort();
Channel<Long> rc = newChannel(BUFFER);
long sum = 0L;
for (int i = 0; i < div; i++) {
long subNum = num + i * (size / div);
new Fiber(() -> skynet(rc, subNum, size / div, div)).start();
}
for (int i = 0; i < div; i++)
sum += rc.receive();
c.send(sum);
}
public static void main(String[] args) throws Exception {
for (int i = 0; i < RUNS; i++) {
long start = System.nanoTime();
Channel<Long> c = newChannel(BUFFER);
new Fiber(() -> skynet(c, 0, 1_000_000, 10)).start();
long result = c.receive();
long elapsed = (System.nanoTime() - start) / 1_000_000;
System.out.println((i + 1) + ": " + result + " (" + elapsed + " ms)");
}
}
}
语言 | 性能 |
---|---|
Golang | 23615ms |
Quasar | 15448ms |
发现Java的性能优势体现出来了。几乎是golang的1.5倍
Quasar 初探
1)协程的目的:当我们在使用多线程的时候,如果存在长时间的I/O操作。这个时候线程一直处于阻塞状态,如果线程很多的时候,会存在很多线程处于空闲状态,造成了资源应用不彻底。相对的协程不一样了,在单线程中多个任务来回自行如果出现长时间的I/O操作,让其让出目前的协程调度,执行下一个任务。当然可能所有任务,全部卡在同一个点上,但是这只是针对于单线程而言,当所有数据正常返回时,会同时处理当前的I/O操作。
2)多线程测试(这里使用100万个线程,来测试内存占用)
for (int i = 0; i < 1000000; i++) {
new Thread(() -> {
try {
Thread.sleep(100000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
使用线程直接卡死
接下来使用协程实现
public static void main(String[] args) throws Exception {
//使用阻塞队列来获取结果。
LinkedBlockingQueue<Fiber<Integer>> fiberQueue = new LinkedBlockingQueue<>();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
for (int i = 0; i < 1000000; i++) {
int finalI = i;
//这里的Fiber有点像Callable,可以返回数据
Fiber<Integer> fiber = new Fiber<>((SuspendableCallable<Integer>) () -> {
//这里用于测试内存占用量
Fiber.sleep(100000);
System.out.println("in-" + finalI + "-" + LocalDateTime.now().format(formatter));
return finalI;
});
//开始执行
fiber.start();
//加入队列
fiberQueue.add(fiber);
}
while (true) {
//阻塞
Fiber<Integer> fiber = fiberQueue.take();
System.out.println("out-" + fiber.get() + "-" + LocalDateTime.now().format(formatter));
}
}
通过上面的测试,可以看出,quasar中Fiber,很像Callable的用法、而且在内存占用上面减少了很多,当然,堆的数量确实不少,但是可以接受。
还是要说明:协程的方式更多用来做I/O密集型的操作。计算密集型的还是使用线程更加合理。
协程原理
原理参考:次时代Java编程(一):Java里的协程
1、Quasar里的Fiber其实是一个continuation,他可以被Quasar定义的scheduler调度,一个continuation记录着运行实例的状态,而且会被随时中断,并且也会随后在他被中断的地方恢复。Quasar其实是通过修改bytecode来达到这个目的,所以运行Quasar程序的时候,你需要先通过java-agent在运行时修改你的代码,当然也可以在编译期间这么干。golang的内置了自己的调度器,Quasar则默认使用ForkJoinPool这个JDK7以后才有的,具有work-stealing功能的线程池来当调度器。work-stealing非常重要,因为你不清楚哪个Fiber会先执行完,而work-stealing可以动态的从其他的等等队列偷一个context过来,这样可以最大化使用CPU资源。
2、那这里你会问了,Quasar怎么知道修改哪些字节码呢,其实也很简单,Quasar会通过java-agent在运行时扫描哪些方法是可以中断的,同时会在方法被调用前和调度后的方法内插入一些continuation逻辑,如果你在方法上定义了@Suspendable注解,那Quasar会对调用该注解的方法做类似下面的事情。
3、这里假设你在方法f上定义了@Suspendable,同时去调用了有同样注解的方法g,那么所有调用f的方法会插入一些字节码,这些字节码的逻辑就是记录当前Fiber栈上的状态,以便在未来可以动态的恢复。(Fiber类似线程也有自己的栈)。在suspendable方法链内Fiber的父类会调用Fiber.park,这样会抛出SuspendExecution异常,从而来停止线程的运行,好让Quasar的调度器执行调度。这里的SuspendExecution会被Fiber自己捕获,业务层面上不应该捕获到。如果Fiber被唤醒了(调度器层面会去调用Fiber.unpark),那么f会在被中断的地方重新被调用(这里Fiber会知道自己在哪里被中断),同时会把g的调用结果(g会return结果)插入到f的恢复点,这样看上去就好像g的return是f的local variables了,从而避免了callback嵌套。
4、上面啰嗦了一大堆,其实简单点讲就是,想办法让运行中的线程栈停下来,好让Quasar的调度器介入。JVM线程中断的条件只有两个,一个是抛异常,另外一个就是return。这里Quasar就是通过抛异常的方式来达到的,所以你会看到我上面的代码会抛出SuspendExecution。但是如果你真捕获到这个异常,那就说明有问题了,所以一般会这么写。