——Lilytask2.5基于Win32thread的实现
段孟成(dmc@net.pku.edu.cn)
Lilytask是以任务为单位的并行编程模型,Lilytask2.5β版最初是在Linux系统上基于POSIX thread实现的,为了更好的适应并行计算环境中的异构性,又在β版的基础上实现了for Windows版,在实现过程中,需要用Win32thread library替换POSIXthread library,下文将主要描述POSIX thread(下文称之pthread)与Win32thread的关系以及在Lilytask2.5 for Windows中的具体实现。
一.什么是线程。
线程(thread)是为了提高系统内程序的并发(concurrency)执行程度而提出来的概念,它是比进程更小的能够独立运行的基本单位。在引入线程的系统中,线程是处理器调度(schedule)的基本单位,而传统的进程则只是资源分配的基本单位。同一进程中的线程共享这个进程的全部资源与地址空间,除此之外,线程基本上不拥有其他任何系统资源,当然,每个线程也必须拥有自己的程序计数器(Program Counter),寄存器堆(register file)和栈(stack)等等。即线程是一个轻量级实体(light-weight entity),它的结构(thread structure)相对简单,在切换速度上非常得快,同一进程中的线程切换不会引起进程的切换,对于并行计算来讲,有效的利用线程能够改善计算的效率,简化计算的复杂性,所以Lilytask正是基于线程实现的。
二.线程的标准。
目前,主要有三种不同的线程库的定义,分别是Win32,OS/2,以及POSIX,前两种定义只适合于他们各自的平台,而POSIX 定义的线程库是适用于所有的计算平台的,目前基本上所有基于UNIX的系统都实现了pthread。本文主要讨论Win32和POSIX的线程定义。
线程的实现一般有两种方法,一是用户级的线程(user-level thread library),另一种是核心级的线程(kernel-level library)。对于用户级的线程来说,它只存在于用户空间中(user space),它的创建,撤销和切换都不利用系统调用,与核心无关;而核心级的线程依赖于核心,它的创建,撤销和切换都是由核心完成的,系统通过核心中保留的线程控制块来感知线程的存在并对线程进行控制。Win32thread是基于核心级线程实现的,而pthread部分是基于用户级线程实现的。
三.pthread和Win32thread的具体实现。
1.关于线程创建和消亡的操作。
1.1 创建和撤销一个POSIX线程
pthread_create(&tid, NULL, start_fn, arg); pthread_exit(status); |
1.2 创建和撤销一个Win32线程
CreateThread(NULL, NULL, start_fn, arg, NULL, NULL); ExitThread(status); |
start_fn是该线程要执行的代码的入口。线程创建后,就拥有了一个TID(thread ID),以后对于该线程的操作都是通过TID来进行的。Win32虽然也定义了TID,但是它对于线程的操作是通过另外定义的一个句柄(handle)来进行的,总之,在线程创建完毕后,都有一个唯一了标识符来确定对该线程的引用。线程的撤销可以显式的调用上面列举的函数来实现,如果没有显式调用撤销函数,则该线程执行的函数(即start_fn)返回时,该线程被撤销。关于创建和撤销线程,POSIX和Win32并无太大的区别。
2.关于线程的等待(join or wait for)的操作。
在多线程模型下,一个线程有可能必须等待其他的线程结束了才能继续运行。比如说司机和售票员,司机只有当售票员确定所有的人都上车了,即售票员的行动结束以后才能开车,在这之前司机必须等待。
2.1等待一个POSIX线程
pthread_join(T1); |
2.2等待一个Win32线程
WaitForSingleObject(T1); |
当调用上面的函数是,调用者会被阻塞起来,直到要等待的线程结束。对于POSIX,线程分为可等待(non-detached)和不可等待(detached),只能对可等待的线程调用pthread_join(),而对不可等待的线程调用pthread_join()时,会返回一个错误。对于不可等待的线程,当线程消亡时,它的线程结构,栈,堆等资源会自动的归还给操作系统;而对于可等待的线程,当它消亡时并不自动归还,而需要程序员显式的调用等待函数来等待这个线程,由等待函数负责资源的归还。在线程创建的时候,你可以且定该线程是否为不可等待,如果没有显式确定,则默认为可等待。当然也可以通过调用pthread_detach()来动态的修改线程是否可等待。在Win32的线程中没有不可等待这个概念,所有的线程都是可以等待的,另外,Win32还提供一个调用WaitForMulitpleObject(T[]),可以用来等待多个线程。
关于为什么要使用等待操作,上面解释的很清楚,但实际上并不是如此,我们之所以要使用等待操作,是因为我们认为序关系中的前驱线程结束以后,后续线程才能从阻塞态恢复执行,在这里我们要明白一个问题,我们等待的仅仅是前驱线程执行的任务结束而不是前驱线程本身的结束,或许前驱线程在执行完任务后会有一些其它的操作,那么等待前驱线程的结束会浪费我们的时间,所以通常不是利用上面的等待函数,而是利用下面要提到的同步机制(synchronization)来解决这个问题。
3.关于线程挂起(suspend)的操作。
线程的挂起是指线程停止执行,进入睡眠状态,直到其他线程调用一个恢复函数时,该线程才脱离睡眠状态,恢复执行。
3.1 挂起和恢复一个Win32线程
SuspendThread(T1); ResumeThread(T1); |
POSIX并没有实现线程的挂起和恢复操作,对于某些场合,挂起操作可能非常有用,但在大多数情况下,挂起操作可能会带来致命的错误,如果被挂起的线程正拥有一个互斥量(mutex)或一个临界区(critical section),则不可避免的会出现死锁状态,所以通常也不使用挂起操作,如果一定要使用,必须检查会不会出现死锁情况。
4.关于线程的强制撤销(cancellation or killing)的操作。
一个线程有可能会通知另一个线程执行撤销操作,比如一个发送信息线程和一个接收信息线程,当发送方法送完所有信息,自身需要撤销时,它必须通知接受方发送完毕并且要求接受方也要撤销。对于上面的这种情况,POSIX称为cancellation,Win32称为killing,在实质上二者并没有多大区别。
4.1 撤销一个POSIX线程
pthread_cancel(T1); |
4.2 撤销一个Win32线程
TerminateThread(T1); |
5.线程的调度(scheduling)
线程的调度机制通常分为两种:一种是进程局部调度(process local scheduling),一种是系统全局调度(system global scheduling)。局部调度是指线程的调度机制都是线程库自身在进程中完成的,与核心没有关系。POSIX对于两种调度机制都实现了,而Win32由于实现的是核心级线程,所以它的调度机制是全局的。线程的调度机制相当复杂,但对于线程库的使用者而不是开发者而言,线程的调度并不是最重要的东西,因为它主要是由操作系统和线程库来实现,并不需要使用者使用多少。
6.线程的同步机制(synchronization)
线程的同步是一个非常重要的概念,也是使用者最需要注意的地方之一。如果线程的同步机制使用不当,非常容易造成死锁。同步机制是基于原子操作(atomic action)实现的,所谓原子操作是指该操作本身是不可分割的。为什么要使用线程同步机制?因为在程序中,可能会有共享数据和共享代码,对于共享数据,我们要确保对该数据的访问(通常是对数据的修改)是互斥的,不能两个线程同时访问这个共享数据,否则会造成错误;而对于共享的代码,如果这段代码要求的是互斥执行(通常把这段代码称为临界区),则也需要同步机制来实现。另外,对于一个线程,可能会需要等待另一个线程完成一定的任务才能继续执行,在这种情况下,也需要同步机制来控制线程的执行流程。通常,同步机制是由同步变量来实现的,一般说来,同步变量分为互斥量,信号量和条件量。
6.1互斥量mutex是最简单的同步变量,它实现的操作实际上就是一把互斥锁,如果一个线程拥有了这个mutex,其他线程在申请拥有这个mutex的时候,就会被阻塞,直到等到先那个线程释放这个mutex。在任何时候,mutex至多只有一个拥有者,它的操作是完全排他性的。
6.1.1 POSIX的mutex操作
pthread_mutex_init(MUTEX, NULL); pthread_mutex_lock(MUTEX); pthread_mutex_trylock(MUTEX); pthread_mutex_timedlock(MUTEX, ABSTIME); pthread_mutex_unlock(MUTEX); pthread_mutex_destroy(MUTEX); |
6.1.2 Win32的mutex操作
CreateMutex(NULL, FALSE, NULL); WaitForSingleObject(MUTEX); ReleaseMutex(MUTEX); CloseHandle(MUTEX); |
POSIX的mutex操作提供了trylock和timedlock的调用,目的是为了防止死锁,Win32的wait操作本身可以设定超时,因此可以用设定超时的方法来模拟POSIX中的trylock,虽然二者在操作的集合上不等势,但显然二者在功能上是等价的。另外,Win32还提供一个叫做CriticalSeciton的互斥量,简单说来,它就是一个轻量级的mutex,并且只能实现统一进程中的线程的同步,不能实现跨进程的线程间的同步。CriticalSection较之mutex来说,更快更高效,而且与POSIX相似,CriticalSection操作提供一个TryEnterCriticalSection的操作,用来监测该CriticalSection是否被锁上。但它没有实现与timedlock相似的功能。
6.1.3 Win32的CriticalSection操作
InitializeCriticalSection(&cs); EnterCriticalSection(&cs); TryEnterCriticalSection(&cs); LeaveCriticalSection(&cs); DeleteCriticalSection(&cs); |
6.2信号量semaphore最初是由E.W.Dijkstra于20世纪60年代引入的。通常,信号量是一个计数器和对于这个计数器的两个操作(分别称之为P,V操作),以及一个等待队列的总和。一个P操作使得计数器减少一次,如果计数器大于零,则执行P操作的线程继续执行,如果小于零,那么该线程就会被放入到等待队列中;一个V操作使得计数器增加一次,如果等待队列中由等待的线程,便释放一个线程。简单的,我们可以通过一个图示来了解P,V操作的意义:
P操作: V操作:
continue
从图中可以看出,信号量的操作实际上是包括了互斥量的。一般地说来,信号量的操作可以在不同的线程中进行,而互斥量只能在同一个线程中操作,当互斥量和信号量要同时操作时,一定要注意互斥量的lock操作和信号量的P操作的顺序,通常应该是信号量的P操作在互斥量的lock操作之前,否则容易出现死锁。而互斥量的unlock操作和信号量的V操作则不存在这种序关系。
6.2.1 POSIX的信号量操作
sem_init(SEM, 0, VALUE); sem_wait(SEM); sem_trywait(SEM); sem_destroy(SEM); |
6.2.2 Win32的信号量操作
CreateSemaphore(NULL, 0, MaxVal, NULL); WaitForSingleObject(SEM); ReleaseSemaphore(SEM); CloseHandle(SEM); |
6.3条件量condition variables是一种非常类似于信号量的同步变量,不同的是,信号量关注的是counter的计数是多少,而条件量关注的仅仅是条件是否满足,换一句话说,条件量可以简单看作是计数器最大取值不超过1的信号量,但在它绝对不是信号量的简单实现,某些情况下,它比信号量更直观。同信号量一样,条件量是由一个待测条件,一组PV操作和一个等待队列组成的。它的PV操作和信号量的PV操作也非常的类似。
但是必须注意一点,在信号量中,lock和unlock是在信号量内部完成的,也就是说不需要使用者显式指定一个互斥量来进行互斥操作,而对于条件量来说,就必须显式地指定一个互斥量来保证操作的原子性。所以条件量总是与一个相关的互斥量成对出现的。
6.3.1 POSIX的条件量的操作
phtread_cond_init(COND, NULL); phtread_cond_wait(COND, MUTEX); phtread_cond_timedwait(COND, MUTEX, TIME); phtread_cond_signal(COND); phtread_cond_broadcast(COND); phtread_cond_destroy(COND); |
其中broadcast是用来唤醒所有等在该条件量上的线程。
Win32中并没有条件量这个概念,但是它实现了一种叫做Event的同步变量,实质上和条件量是差不多的。
总体上来讲,POSIX和Win32实现的线程库在功能上基本上重叠的,也就是说用其中一种线程库实现的程序大多数的时候都能够比较容易的用另一种线程库来实现。下面列出了一张表,对比了一下两个线程库的异同:
| POSIX thread library | Win32 thread library |
设计思想 | 简单 | 复杂 |
级别 | 用户级/核心级 | 核心级 |
调度策略 | 进程局部/系统全局 | 系统全局 |
线程挂起/恢复 | 未实现 | 实现 |
互斥量 | 实现 | 实现 |
信号量 | 实现 | 实现 |
条件量 | 实现 | 实现(事件对象Event) |
线程创建/撤销 | 实现 | 实现 |
线程等待 | 实现 | 实现 |
四.Lilytask2.5的Win32thread实现。
Lilytask中涉及到的Win32thread,主要表现在线程的创建和同步两个方面上,下面就简单的讲述以下这两个方面的实现。
1.线程的创建以及相关的处理。
在每一个节点上,主线程要创建相应的线程:接收消息线程,发送消息线程,如果同一节点上的taskpool数目超过一个,则还要创建从处理线程,在lily_initial函数里边,要创建这几个线程:
//创建接收线程,启动_thread_routine_recv thread_id=CreateThread(NULL,0,(LPTHREAD_START_ROUTINE)_thread_routine_recv, NULL, 0, NULL); //创建发送线程,启动_thread_routine_send thread_id2=CreateThread(NULL,0,(LPTHREAD_START_ROUTINE)_thread_routine_recv, NULL, 0, NULL); //创建从处理线程 for(i=0; i<numofthrs-1; i++) { thrid=CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)lily_run_ready_task, (void*)i, 0, NULL); } |
在创建完线程后,主线程和从线程都要求取得本线程的tid以及handle。线程的id用来确定该线程对应的taskpool是哪一个,线程的handle用来引用其自身,在wait的过程中会用到。在pthread的实现中, tid和handle是一体的,由pthread_self调用就可以得到;而对于Win32thread来说,要稍微的麻烦一点,tid和handle是不同的,所以要分别保存线程的id和handle。得到线程的id用GetCurrentThreadId,而与此对应的有GetCurrentThread,是不是用该函数就能得到线程的handle呢?答案不一定的。GetCurrentThread返回的是一个pseudo-handle,是一个伪句柄,只能由GetCurrentThread的调用者引用,即thread本身才能引用这个pseudo-handle,而我们的要求是其他的线程也可以引用这个handle,所以GetCurrentThread并不能满足我们的要求。但是,我们可以通过调用DuplicateHandle来复制一个真实的句柄,DuplicateHandle可以把一个进程中的一个handle(可以是pseudo-handle)复制给另一个进程的一个handle,得到是一个真实的其他线程也可以引用的handle:
//保存线程的id taskpool[numofthrs-1].pthid2=GetCurrentThreadId(); //保存线程的handle DuplicateHandle(GetCurrentProcess(),GetCurrentThread(),GetCurrentProcess(), &taskpool[numofthrs-1].pthid, 0, FALSE, DUPLICATE_SAME_ ACCESS); |
2.线程的同步问题。
Lilytask基于任务并行来实现,并且定义了任务的序关系,这就使得任务间必然存在等待的情形,所以在Lilytask中,线程的同步问题非常的重要。在Lilytask中,互斥量被大量使用,一方面是大量临界资源的存在,另一方面是配合条件量的使用,上文已经指出,Win32 thread库中并没有实现条件量,与此对应的是Event Object,但是在Lilytask2.5βforWindows的实现中,我们并没有用Event Object,一是因为利用Event Object的实现相对麻烦一些,而信号量则相对简单易懂;另一原因是在Lilytask中用到的条件量,在大多数时候可以看作是一个最大值不超过1的信号量,基于上面的两个原因,Lilytask for Windows的主要的同步机制是利用互斥量和信号量来实现的。在Win32 thread中提供一个API:SignalObjectAndWait,可以用这个函数来模拟条件量的wait。条件量的wait操作在上文中的图示已经画出来了,其关键是开始时要lock临界区,然后判断条件,如果条件不成立,则unlock和sleep,其中unlock和sleep必须是原子操作的,正好SignalObjectAndWait也具有这个特性,所以用来模拟条件量非常的方便。
//互斥量作为临界区的锁来使用 WaitForSingleObject(taskpool[i].mutex_readylist_access, INFINITE); taskpool[i].isSignal=TRUE; …… //临界区操作 ReleaseMutex(taskpool[i].mutex_readylist_access);
//互斥量与信号量配合使用,实现的条件量的Wait操作 WaitForSingleObject(taskpool[i].mutex_readylist_access, INFINITE); …… //临界区操作 SignalObjectAndWait(taskpool[i].mutex_readylist_access, taskpool[i].cond_readylist_access, INFINITE, FALSE); WaitForSingleObject(taskpool[i].mutex_readylist_access, INFINITE); …… //临界区操作 ReleaseMutex(taskpool[i].mutex_readylist_access); |
信号量的释放操作,相对而言就比较简单,与pthread下的实现并无二样。
除此之外,线程的同步还涉及到lily_finalize时要等待所有从线程的结束,虽然我们说过完全可以用信号量计数的方法取代wati(join)线程的方法,但就Lilytask这个实例来讲,用wait线程的方法更简单明了。
//等待从线程的结束 for(i=0; i<numofthrs-1; i++) { WaitForSingleObject(taskpool[i].pthid, INFINITE); …… } |
另外还有一些关于线程的同步操作,比如pthread中trylock, timedlock等等,在上文的讨论中已经详细的说明了在Win32thread环境中的解决方法,就不一一赘述了。
五.总结。
总的说来,这二者在实现上是不一样的,但在提供给用户的接口上,基本上是一样的(当然,你可以说API的名字是不一样的,但我们探讨仅仅是API的实质即它提供给用户的功能接口)。对于Lilytask,之所以要做for Windows的版本,是基于系统的异构性的原因,Lilytask可以向上为用户屏蔽掉系统异构的差异,提供给用户一个不透明的编程模式,用户只需用Lilytask的原语写并行程序,不需要考虑系统的异构性,即用户写得程序无需做任何改动就可以在不同的系统上运行,而这些解决异构这些繁琐的问题这是由Lilytask的预编译器调用不同的库来实现的,大大的减轻了用户的负担。
参考资料:
[1]BilLiews & Daniel J. Berg, Pthreads Primer——AGuide to MultiThreaded Programming.