当前位置: 首页 > 文档资料 > larva 中文文档 >

GIL 并发控制

优质
小牛编辑
128浏览
2023-12-01

如果一个语言要实现支持并发执行的接口,则一般来说需要在并发控制上下功夫,原因就是前面说的,由于虚拟机实现的细节问题,直接依赖宿主环境的并发容易出问题。简单地,以使用宿主的线程为例。假如源语言的线程对应宿主环境的真线程,那么同步操作就需要用到线程间的互斥量,比如锁,信号量等

一个程序需要并发,一般来说有三个原因: 一,为充分利用多核cpu资源,提高计算速度。这个原因是很重要,但在实际中其重要性我觉得被过分夸大了,因为首先需要将问题合理分配使得可以并行,其次应用场景是计算密集型,符合这两个条件的场景不算很多,再者,如果问题已经合理分配了,那简单地多开几个进程也能解决。这个不讨论 二,实现感觉上的并行执行,这在UI程序中比较重要,主要是考虑使用者的体验流畅,比如当一个客户端程序突然有一个耗时比较长的计算任务时,不至于将界面卡住 三,充分利用硬件资源,当一个线程阻塞在IO(或其他)操作时,cpu资源能给其他线程使用,这在后台服务器程序中比较常见

后面两条刚好对应线程的两种调度:标准调度(分时)和阻塞调度 标准调度的时候,一个线程执行一段时间(或其他计数法)后,调度到另一个线程 阻塞调度的时候,一个线程如果进入一个阻塞调用,调度到另一个线程

虚拟机中实现调度控制,跟操作系统差不多,区别在于,分时调度的时候,操作系统一般是使用每隔一定时间产生的时钟中断,而虚拟机可以做成线程自己计数,差不多了就主动切换到另一个线程,这个跟阻塞调用的主动切换是一样的。具体的切换策略以GIL为例,这也是容易实现且效果也比好的一种方式,python和ruby的标准实现都在用

GIL全称全局解释器锁,就是一个互斥锁,在虚拟机中,每个源语言的线程对应一条真线程,逻辑上只有当一个线程持有GIL的时候,才能执行。这里说的逻辑上,是指虚拟机字节码层面,且不包括阻塞,比如:

release_gil();   
... //只要这段代码和环境没有冲突即可,比如局部变量计算   
acquire_gil();

因此在python中,使用C扩展就可以绕过GIL,实现占多核,但是并发执行的代码决不能影响python虚拟机的环境,数据上要分离

python中当一个线程正在执行时,其他线程或者在做阻塞调用,或者处于就绪状态在acquire_gil(),因此当需要调度的时候,简单地release_gil()即可

标准调度可以按时间来,比如每个字节码解释循环后取一次时间,但是取时间在系统中一般是一个比较耗时的工作,可以使用另一种做法,根据字节码来计数,每执行一定量字节码后进行调度:

extern int tick_count = 0; //全局的   
//下面是局部的,execute函数内部   
int idx = 0;   
for (;;)   
{   
    ++ tick_count;   
    if (tick_count > MAX_TICK)   
    {   
        //标准调度   
        tick_count = 0;   
        release_gil();   
        acquire_gil();   
    }   
    Inst inst = inst_list[idx];   
    ++ idx;   
    switch(inst.code)   
    {   
        ...   
    }   
}

虚拟机维持一个全局的tick_count,表示累计的连续执行的字节码数量,当连续执行超过MAX_TICK条指令时,就执行标准调度,重置tick_count并释放gil。MAX_TICK的值一般可根据具体情况选择,太大了实时性体验不好,太小了由于频繁加解锁又对性能有影响,需要一个平衡

有两点需要注意: 一,tick_count不能是局部变量,是由于执行CALL_FUNC指令时会递归调用execute 二,tick_count的更新并不精确,会受一些其他因素影响,比如switch内部的指令预测跳转,这时候不会更新tick_count,这实际上把带预测的关联指令变成了一个原子操作,如果预测链比较长,就会出现某个线程执行很久的情况。当然也可以在switch代码里面合适的地方对tick_count做操作和判断,但一般来说没必要,因为指令预测之类的影响会控制在一定范围

阻塞调度就更简单了,当虚拟机需要执行一个可能阻塞的地方的时候,释放GIL,执行完成后在加锁:

void sleep(Object[] arg)   
{   
    //先检查参数   
    if (arg.length != 1) ...;   
    if (!(arg[0] instanceof IntObj)) ...;   
    int sec = ((IntObj)arg[0]).value;   
    release_gil();   
    sys_sleep(sec); //调用系统的sleep   
    acquire_gil();   
}

阻塞调度的时候,可以选择更新或不更新tick_count,都没有什么关系,因为不更新只可能让切换更频繁,不影响实时性体验

假如对实时性体验要求不高,可以将MAX_TICK设为正无穷,相当于去掉标准调度,将抢占式调度修改为非抢占调度,省去了大量加解锁的操作,整体性能会有提升。但是直接使用操作系统的线程还是有一些实现相关的问题: 一、一般os中每个线程都有自己的栈,虽然物理内存是实际使用时按页映射的,但消耗了进程地址空间,而线程栈一旦溢出进程就崩溃了,所以一般都搞大点,在32bit下这就给线程数量造成限制(32bit还有低端内存的问题) 二、线程调度要进入内核态,而这时一个非常耗资源的过程,线程太多的话会导致大量cpu时间浪费在调度计算上,虽然大部分时候我们只需要很简单的调度策略,但操作系统为了通用性,下层的算法还是比较复杂的。而且os还要处理其他关于线程的工作,可以在win下面调小线程栈,开上几万个线程,看看有没有卡死

因为这两点原因,在后台服务器处理高并发请求时一般不会一个请求或一个用户就给开一个线程,而是用线程池或单线程异步的方式来做,但如果阻塞时间较久(比如网络延时),线程池也不能实现充分利用资源,而单线程异步代码写起来又比较反人类直觉,于是就出现了结合两者的并发编程实现,接口、使用和线程一致,底层转为单线程异步实现,不陷入内核态,在用户层调度的线程