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

并发执行

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

以我的经验,一提到并发执行,90%的人都会提到线程,的确这玩意用的很广泛,综合来说各方面都还可以。虽然很多语言都内置了线程库,C++11也有了,但严格来说线程是跟操作系统相关,具体说,如果操作系统支持线程,则语言的线程库简单封装下就可以了,如果操作系统不支持(如一些unix系统),那就比较麻烦了,简单的可以去掉线程库,或接口返回异常,复杂的可能自己实现一个用户态的线程机制

一个语言实现中如果要用到线程,最好还是操作系统原生支持,具体说就是直接包装宿主语言的线程接口,这样实现起来相对简单。在多线程环境中,前述的env只要一点点变化即可:函数调用和异常按线程分开,弄一张表即可。具体到实现,由于局部变量可以通过local_env对象来在每个宿主函数内部存储,只需考虑异常机制即可,还可以有更好的封装:

class Env   
{   
    ConstTable const_table;   
    GlobalTable global_table;   
    ThreadTable thread_table;   
}

实现也比较简单:

Tid start_new_thread(Function entry, Object[] arg_list)   
{   
    Thread t = new Thread();   
    t.run();   
    env.thread_table.set(t.get_tid(), t);   
    return t.get_tid();   
}

建立一个线程,然后直接以tid为索引扔到env里面即可,这里的Thread是实现的一个对应源语言的线程对象,里面包含一些必要的状态,例如异常traceback等

实际上,如果把所有线程都放在env里面,各线程之间就形成一个并列和对等的关系,env是公用的,线程间通讯也可以固化到Thread对象中。整个虚拟机入口甚至可以做成这样: void main(String[] argv) { Code code = load_code(argv[1]); //读取字节码文件 ... //一些必要初始化 Object[] internal_argv = convert_argv(argv); //将宿主语言的参数列表转成源语言的内部数据格式 Tid main_tid = start_new_thread(get_main_func(code), internal_argv); //启动主线程 join_thread(main_tid); } 这样做虽然会多启动一个线程,但源语言的逻辑主线程就和其他线程对等了,可能会有些好处。而且宿主语言的主线程可以做一些其他事情,上面在启动逻辑主线程后就join了,可以修改这个逻辑,比如循环检测join env中所有线程,就可以做到即便逻辑主线程退出了,虚拟机也会等待所有运行中的线程退出,提高一些安全性和开发的便利。虚拟机的主线程还可以做一些实时监控或统计等后台工作

另外还需要注意一下异常机制的问题,每个线程有自己的异常栈,如果是单线程程序,那么有异常没catch就直接打印并退出程序即可,但多线程可能会有多个未捕获的异常,即便线程结束了,也需要保留下来,可以在join的时候以某种形式返回。当然更推荐的一种做法是,用源语言编程的时候在线程入口函数中try一下

使用宿主语言的线程接口实现线程虽然简单,也是流行的做法,但是需要考虑到同步问题,这里的同步不是指用源语言编程的时候的同步,而是虚拟机在实现的时候需要注意自己的一些细节可能导致的线程问题。典型的是,源语言中一个非常简单的“原子”操作,在虚拟机中可能是一个相对复杂的过程,例如python的标准实现使用的引用计数机制

在python的标准实现中,每个对象维持一个引用计数,当有新的外部引用的时候,这个值就+1,反之一个外部引用被撤销的时候,就-1,如果减到0,就说明这个对象没有人用到了,就销毁,再考虑到动态性,python中一个很简单的a=b赋值操作都涉及到这些计算(伪代码,假设a和b都是全局变量): [plain] view plaincopy if (global_table.has_key("a"))
{
//a要引用其他对象,原来引用的对象的计数减少
global_table.get("a").dec_ref(); //dec_ref可能会导致对象销毁
}
tmp = global_table.get("b");
tmp.inc_ref(); //增加对象的引用计数
global_table.set("a", tmp); //赋值

如果有两个线程同时执行a=b,在宿主语言中并发执行,那么可能出现很多非预期的行为,比如,两个线程同时执行上述dec_ref,可能导致一个对象的内存被free两次,很可能导致虚拟机崩溃(就算不崩内存也乱了)

如果虚拟机将这个问题抛给源语言,则可能导致源语言中的互斥操作非常复杂,比如上面的a=b就可能变成: [plain] view plaincopy lock
a=b
unlock
这和java的sync代码段很像,不过我猜没有人会使用这种语言,因为写起来会非常麻烦,再者引用计数机制是虚拟机实现的选择,不应该让程序员为细节来买单

另一个原因是,某些和多线程相关的特性,宿主语言的实现并不能满足上层需求,还是需要虚拟机做点动作的,比如C的volatile并不包括acquire和release语义,而如果用C做宿主实现一种带这两种语义的静态类型语言(即便和C非常相似),还是需要在编译器和虚拟机上下功夫

回到上面的问题,python采用了一种很简单的办法来解决,全局解释器锁GIL,一个进程运行时有一个全局锁,每个线程只有获取了这个锁才能执行,这种做法初看很霸道,似乎我们只要对每个对象加锁就行,但这种做法有两个问题:

一,直接对对象加锁不能解决引用计数的问题,例如: [plain] view plaincopy void dec_ref()
{
lock(this.lock);
-- this.ref;
if (this.ref == 0)
{
this.destroy();
}
unlock(this.lock);
}
首先就是逻辑上的问题,既然已经执行了destroy,那么下面的unlock就可能有问题了,当然可以将lock放在对象外面,但这样一来lock怎么销毁呢;其次是并发的问题,如果一个线程在执行这段代码的时候另一个线程阻塞在lock这里,那么等第二个线程进来时就挂了,因为已经销毁了 这个问题的根源在于没搞清楚对象销毁的意义,对象销毁是一个和它所在的环境(引用它的地方)交互的过程,因此就算要加锁,也要在上层加,但是: [plain] view plaincopy a="abc"
b="abc"
如果两个线程分别执行del a和del b,就算分别锁住了环境(global的a和b的位置),执行dec_ref的时候也会出现: [plain] view plaincopy -- ref; //线程1
-- ref; //线程2
destroy(); //线程1,因为ref为0了,销毁
destroy(); //线程2,崩了
因此,需要两把锁,先锁环境,再锁代码,这非常麻烦,实现上也非常容易出错

二,就算解决了上述问题,也要面对性能和其他开发问题,事实上在python的发展中的确有一个去除GIL的标准版本的分支,做法就是细粒度的锁,以求占多核,但其在单核上的效率测试结果惨不忍睹,最终还是失败

关于python的多线程环境实现,可以去看《Python源码剖析》,是实现清晰也比较经典的模型,包括很多日志里没法详说的细节问题。上面的分析也回答了之前在《语言理论的概念和误解》中的一个问题,为何Jython能去除GIL,多线程占多核,而效率也没下降很多,个人认为最主要的原因就是Jython利用了jvm的GC机制,减少了并发时类似的问题,换句话说,宿主语言本身的机制比较完善,虚拟机实现就能简化很多了