GMP模型(一) 使用Java语言实现一个简单的协程?

golang最大一特色便是其原生支持goroutine,轻量的goroutine的实现可以使得程序轻松支持超高并发。想要深入学习golang,少不了详细理解goroutine原理。

goroutinegolang实现的用户级线程,其具有以下几个特点:

  • 启动代价小
  • 栈大小可以动态伸缩,初始大小2KB
  • 线程在用户态调度,切换线程无需在用户态和内核态之间来回切换

总之,相比于线程,goroutine十分轻量。然而由于是在用户态调度线程,因此goroutine的调度也变的比较复杂,golanggoroutine的调度主要依赖于GMP模型,本系列文章也主要是围绕GMP模型的相互配合而展开。

简版GMP

为了更好的理解GMP模型,我们先尝试使用Java来实现一版简单的goroutine。线程的本质,其实也就是对任务的封装。因此对于coroutine的本质,还是一个可执行的Task

首先,定义goroutine接口:

public interface Goroutine {
    void run();
}

这个接口是对每个goroutine的封装。

其次,定义Machine对象:

public class Machine {

    //全局任务队列
    private LinkedBlockingQueue<Goroutine> queue;

    //OS Thread id
    private int id;

    public Machine(LinkedBlockingQueue<Goroutine> queue, int id) {
        this.queue = queue;
        this.id = id;
    }

    public void run() {
        //开启OS Thread,从队列中获取任务并执行
        new Thread(() -> {
            for (; ; ) {
                try {
                    //获取任务
                    Goroutine goroutine = queue.take();       
                    //执行任务
                    goroutine.run();
                } catch (InterruptedException e) {
                    return;
                }
            }
        }).start();
    }
}

Machine主要用来启动OS Thread,然后不断地从全局队列中获取任务并执行。

最后,封装Coroutine类,使其可以方便的对外暴漏功能:

public class Coroutine {

    public final static int CPU_NUM = 4;
    //初始化全局队列
    private LinkedBlockingQueue<Goroutine> queue = new LinkedBlockingQueue<>();

    public void start() {
        //启动`CPU`数量的Machine,
        for (int i = 0; i < CPU_NUM; i++) {
            Machine machine = new Machine(queue,i);
            machine.run();
        }
    }
    //提交goroutine方法,对应golang的go关键字
    public boolean run(Goroutine goroutine) {
        try {
            queue.put(goroutine);
        } catch (InterruptedException e) {
            return false;
        }
        return true;
    }
}

Coroutine启动了CPU数量的Machine,同时初始化全局队列,并对外暴露run方法用来提交任务。

到这里,简单的goroutine便成功实现,我们可以尝试简单的使用:

    public static void main(String[] args) {
        //初始化Coroutine
        Coroutine coroutine = new Coroutine();
        coroutine.start();

        //启动10个gorouine,每个goroutine循环打印1-10
        for (int i = 0; i < 10; i++) {
            coroutine.run(() -> {
                for (int j = 0; j < 10; j++) {
                    System.out.println(j);
                }
            });
        }
    }

熟悉Java的同学可能一眼看出来,这不就是一个线程池么?

GMP的本质便是一个全局的线程池,在golangGMP模型中,M便是负责真正执行任务的OS Thread,G则是对任务的封装。P则是本地任务队列。

这里先讲一讲P的由来,从上面的简版的Goroutine实现来看,所有的Machine对象共享一个全局任务队列,这会使得每次添加任务和获取任务都需要加锁,这使得调度的开销非常大,借助局部性原理,我们可以给Machine添加一个本地队列,这个队列会和Machine进行绑定,当Machine执行的代码会创建的新的goroutine的时候,会优先新创建的goroutine保存在本地,当本地容量不足时再存放在全局队列中。这里的本地队列便是P

可以看到一个P在同一时间只属于一个M,因此不存在加解锁的开销。


进一步,根据上面的简版的Goroutine的实现,我们提出当前实现版本会遇到的问题:

  • 当存在goroutine执行系统调用导致线程阻塞时(例如IO),这会影响整个系统的调度
  • 当存在某个goroutine需要执行的任务过多,导致占用OS Thread时间过长,这会影响到队列中其他goroutine的执行
  • 当存在某个goroutine需要暂时挂起,例如sleep或等待条件唤醒时,如何将当前gouroutine暂时踢出队列。
  • 加入本地队列P的实现之后,可能存在某个P存储的任务非常多,而某个P一个任务没有,这种情况应该如何解决?

我们接下来通过分析golang GMP相关源码来看golang是如何处理以上问题。