Douglas C. Schmidt
本论文描述ACE面向对象的线程封装C++类库的设计,该类库使程序员与Solaris线程、POSIX pthreads及Win32线程之间的差异相屏蔽;并从最终用户和内部设计的视角来展现其体系结构,并讨论了关键的设计和实现问题。读者将获得对总体设计方法,以及在多种软件质量因素,如质量、可移植性及可扩展性之间所做的权衡的理解。
某些类型的分布式应用通过使用并发模式执行任务来从中获益。对于多处理器平台上的网络服务器,并发特别有助于改善性能和简化编程。对于服务器应用,使用线程来并发地处理多客户请求常常比下面的设计方法要更为方便和更不易出错:
本论文描述ACE自适配通信环境[1]中包含的C++类库。ACE封装并增强了由Solaris 2.x线程[2]、POSIX Pthreads[3]及Win32线程[4]所提供的轻量级并发机制。
在本论文中介绍的材料面对的是那些有兴趣了解线程的面向对象(OO)并发编程的战略和战术的技术人员。读者被假定熟悉一般的OO设计和编程技术(比如设计模式[5]、应用构架[6]、模块性、信息隐藏和对象建模[7])、OO表示法(比如OMT[8]),基本的的C++编程语言特性(比如类、继承、动态绑定和参数化类型[9])、基本的UNIX系统编程概念(比如进程管理、虚拟内存和进程间通信[10]),以及网络术语(比如客户/服务器体系结构[11]、RPC[12]、CORBA[13]和TCP/IP[14, 15])。
一般而言,理解本论文并不需要对并发有深入的了解;特别地,也不需要对Solaris/POSIX/Win32多线程和同步机制有深入的了解。对并发编程和多线程的综述在4.3介绍,在其中定义了关键的术语,并概述了多种用于在Solaris 2.x、POSIX pthreads和Win32线程上进行并发编程的可选机制。
本论文被组织如下:4.2给出对ACE OS线程封装库的目标的综述,并概述该库的组件的面向对象体系结构。4.3一般性地介绍并发编程的相关背景材料,并特别介绍了Solaris多线程模型。4.4介绍一种激发了ACE线程封装库的设计的最终用户视点,并聚焦于从并发客户/服务器应用中精选的一个使用实例。4.5详细描述ACE线程封装库的公共接口和内部设计。4.6介绍了若干例子,演示在4.5中定义的OO组件。最后,4.7给出结束语。
与前几代SunOS相比,现代操作系统(比如Solaris、OSF/1、Windows NT和OS/2)的一种显著特性是其集成的对内核级和用户级多线程及同步的支持。但是,现有的与这些操作系统一起发布的多线程和同步机制都是用C写成的相对低级的API。混合使用C++类和低级C API来开发应用给开发者造成了不可接受的负担。在单个应用中混合这两种风格将导致面向对象和过程编程之间的“阻抗”失配。这样一种混合的编程风格让人迷惑,并会带来慢性的维护问题。
为避免让每个开发者实现他们自己特别的OS线程机制的C++包装,ACE提供了一组在此论文中描述的面向对象的并发组件。这些ACE组件为并发编程提供了可移植和可扩展的接口。该接口简化了用于开发客户和服务器的线程管理和同步机制。它已被移植到POSIX pthreads标准的许多试验版本[3]、Solaris线程[2]、Microsoft Win32线程[4],以及VxWorks tasks。
与封装和简化OS线程机制的并发底层的目的相结合,ACE OO线程封装类库正在被开发以响应下列常见的应用要求:
ACE OO线程类库被开发用以实现下列设计目标:

图4-1 ACE OO线程封装组件的对象模型
图4-1中的Booch对象模型演示了ACE线程封装类库中的组件。这些组件包括下面描述的C++类和类属。
4.2.2.5 ACE主动对象(Active Object)类属
大多数UNIX系统程序员都熟悉传统的进程管理系统调用(比如fork、exec、wait和exit)。但是,他们关于正在形成中的UNIX多线程和同步机制(比如Solaris线程[2]、POSIX pthreads[3],或是Win32线程[4])的经验却较少。这一部分将给出对并发编程和Solaris线程的相关背景材料的综述。对并发编程和Solaris/POSIX/Win32线程的更为详细的讨论见[2, 18, 19, 3,
4]。
进程是使程序指令得以执行的一组资源。这些资源包括虚拟内存、I/O描述符、运行时栈、信号处理器、用户和组id,以及访问控制令牌。在早期的UNIX系统上(比如SunOS 4.x),进程是“单线程”的。在UNIX中,单线程程序中的操作通常是同步的,因为控制总是在程序(也就是,用户代码)中,或是在操作系统中(经由系统调用)。在某种程度上,传统UNIX进程的单线程特性简化了编程,因为没有程序员显式地进行干预,进程不会与其他进程相互干扰。
但是,使用单线程进程,有许多应用很难开发(特别是网络服务器)。例如,单线程网络文件服务器不能长期阻塞以处理一个客户请求,因为其他客户的响应性会受到损害。有若干常用方法可用以避免阻塞在单线程服务器中:
该方法的主要缺点是持续时间长的会话必须被开发为有限状态机。当状态的数目增加时该方法将变得相当笨拙。此外,因为只能使用非阻塞的操作,很难通过像“I/O流” 这样的技术、或是数据和指令缓存中的本地引用方案来改善性能。
一般而言,要正确地使用协同例程很复杂,因为开发者必须通过周期性地显式派生线程控制来人工地进行任务占先。而且,每个任务必须只执行相对较短的时间。否则,客户将会觉察请求正在被顺序地、而非并发地处理。协同例程的另一局限是,如果OS在任务引发页错误时阻塞进程中所有的任务,应用的性能可能会下降。此外,单个任务(例如,进入了一个无限循环)的失败可能会挂起整个进程。
但是,fork和exec的开销和不灵活使得动态的进程请求对于许多应用来说都极为昂贵和复杂。例如,对于持续时间短的服务(比如解析IP地址的以太网号 、从网络文件服务器获取磁盘块,或是在SNMP
MIB中设置属性)来说,进程管理开销就太过度了。而且,使用fork和exec很难对调度和进程优先级进行细粒度的控制。此外,在共享内存段中共享C++对象的进程必须对虚表指针的位置做出不可移植的假定。
多线程机制提供了更为优雅,有时也更为高效的方法来克服上述的传统并发进程技术的局限。线程是在进程的上下文中执行的单序列的指令步骤。除了指令指针,线程还包括其他的一些资源,比如函数启用记录的运行时栈、一组通用寄存器,以及线程专有的数据。
传统的工作站操作系统(比如UNIX的一些变种[2, 21, 22]和Windows NT[4])支持多进程(每一个进程包含1或多个线程)的并发执行,每个进程可包含1或多个线程。进程充当被保护的单元、和在单独的硬件保护地址空间中进行资源分配的单元。线程充当在进程地址空间中运行的执行单元,该线程与0或多个线程共享此地址空间。
因为如下原因,在相互分离的线程、而不是进程中实现执行多任务的并发应用常常是有益的:
这一部分总结Solaris 2.x提供的多进程(MP)和多线程(MT)机制的相关背景材料。其他的线程模型和实现(比如SGI、Sequent、OSF/1和Windows NT)的细节有所不同,但基本的概念都是非常类似的。
相对来说,传统的UNIX进程是一种“重量级”的实体,其中含有一个单线程控制。相反,Solaris上可用的基于线程的并发机制要更为成熟、灵活和高效(在适当使用时)。如图4-2所示,Solaris MP/MT体系结构在两个层面上运作(内核空间和用户空间),并含有以下4种组件:

图4-2 Solairs 2.x多进程和多线程体系结构
对于分时调度器类别(缺省),调度器通过“占先”(preemption)将可用的PE在多个活动的LWP间进行划分。通过这种技术,每个LWP运行一段有限的时间(通常为10毫秒)。当前LWP的时间片到期后,OS调度器选择另一个可用的LWP,执行一次上下文切换,并将被占先的LWP放置到一个队列中。内核使用若干标准(比如优先级、资源可用性、调度类别,等等)来调度LWP。在分时调度器类别中,没有固定的LWP执行顺序。
Solaris 2.x提供一种多层的并发模型,允许使用下面的两种模式来派生和调度应用线程:
重新调度绑定线程需要一次内核级上下文切换。同样地,绑定线程上的同步操作也需要OS内核的干预。当应用被设计利用在硬件平台上可用的并行性优点时,绑定线程最为有用。因为每个绑定线程都要求分配内核资源,分配大量绑定线程可能导致效率低下。
取决于应用和/或库与一个进程相关联的内核线程的数目,可以在多PE上并行执行一或多个非绑定线程。因为每个非绑定线程并不分配内核资源,有可能分配数量相当大的非绑定线程,而不会显著地降低性能。
在多处理器上,可以在分离的多个PE上并行地运行多于一个的LWP。在单处理器上,在任何时刻只能有一个活动的LWP。不管是怎样的硬件平台,程序员必须确保对共享资源(比如文件、数据库记录、网络设备、终端,或共享内存)的访问是依次进行的,以防止“竞争状态”(race condition)。竞争状态在两个或多个并发LWP的执行顺序会导致不可预测和错误的结果时发生(比如数据库记录被留在了不一致状态)。竞争状态可使用4.3.5描述的Solaris 2.x同步机制来加以排除。这些机制序列化对共享资源的代码临界区的访问。
除了并发控制的挑战,当使用多线程(而不是多进程,或单线程的反应式事件循环)实现并发应用时还会出现以下的限制:
因为线程不受保护,进程中一个有缺陷的服务可能破坏它与在进程的其他线程中运行的服务共享的全局数据结构。于是这就可能导致不正确的结果,毁坏整个进程,致使网络服务器无限期地挂起,等等。一个相关的问题是在某个线程中调用的某些UNIX系统调用可能对整个进程产生不希望产生的副作用。例如,exit系统调用具有销毁进程中所有线程的副作用(应使用thr_exit来终止当前线程)。
在有些环境中多线程可以显著地提高性能。例如,通过在多处理器平台上运行,一个多线程的面向连接的应用网关可以从中获益。同样地,在单处理器上,专事I/O的应用也可以从多线程中获益,因为计算涉及到通信和磁盘操作。
这一部分概述并演示在Solaris 2.x、POSIX pthreads和Win32线程中可用的同步和线程机制。在这些系统中,线程在单进程地址空间中共享若干资源(比如打开的文件、信号处理器,以及全局内存)。因此,它们必须利用同步机制来协调对共享数据的访问,以避免发生4.3.4所讨论的竞争状态。为演示对同步机制的需要,考虑下面的C++代码段:
typedef u_long
COUNTER;
COUNTER
request_count; // At file scope
void *run_svc
(Queue<Message> *q)
{
Message *mb; //
Message buffer
while
(q->dequeue (mb)) > 0)
{
// Keep track of number of requests
++request_count;
// Identify request and
// perform service processing
here...
}
return 0;
}
该代码形成了一个网络看守(比如用于医学成像的分布式数据库,或分布式文件服务器)的主事件循环部分。在代码中,主事件循环等待消息从客户到达。当消息到达时,主线程通过dequeue方法将它从消息队列中移除。然后取决于接收到的消息的类型,线程执行某种处理(例如,图像数据库查询、文件更新,等等)。request_count变量追踪到来的客户请求的数量。该信息可用于更新SNMP MIB中的属性。
只要run_svc在单线程控制中执行,上面所示的代码工作良好。但是,当run_svc由运行在不同的PE上的多线程控制同时执行时,在许多多处理器平台上将会产生不正确的结果。这里的问题是这些代码并非是“线程安全”的,因为针对全局变量request_count的自增操作含有一个竞争状态。因而,不同的线程可能会增加存储在它们自己的PE数据缓存中的request_count变量的陈旧版本。
这一现象可通过执行下面的例4-1中的C++代码来演示,运行环境为一台运行Solaris 2.x操作系统的共享内存多处理器。Solaris 2.x允许多线程控制在共享内存多处理器上并行执行。下面所示的例子是上面演示的网络看守的简化版本:
例4-1
typedef u_long
COUNTER;
static COUNTER
request_count; // At file scope
void *run_svc
(int iterations)
{
for (int i = 0;
i < iterations; i++)
++request_count; // Count # of
requests
return (void *)
iterations;
}
typedef void
*(*THR_FUNC)(void *);
// Main driver
function for the
//
multi-threaded server.
int main (int
argc, char *argv[])
{
int n_threads =
argc > 1 ? atoi (argv[1]) : 4;
int
n_iterations = argc > 2 ? atoi (argv[2]) : 1000000;
thread_t t_id;
// Divide
iterations evenly among threads.
int iterations
= n_iterations / n_threads;
// Spawn off N
threads to run in parallel.
for (int i = 0;
i < n_threads; i++)
thr_create (0, 0, THR_FUNC
(&run_svc),
(void *) iterations,
THR_BOUND | THR_SUSPENDED,
&t_id);
// Resume all
suspended threads
// (threads
id’s are contiguous...)
for (i = 0; i
< n_threads; i++)
thr_continue (t_id--);
// Wait for all
threads to exit.
int status;
while (thr_join
(0, &t_id, (void **) &status) == 0)
cout << "thread id =
" << t_id
<< ", status = "
<< status << endl;
cout <<
n_iterations << " = iterations\n"
<< request_count <<
" = request count"
<< endl;
return 0;
}
Solaris thr_create线程库例程被调用n_thread次,以派生n个新线程控制。在此例中,每个新创建的线程都传递iterations的值给run_svc函数,作为它唯一的参数,并执行之。该值使得run_svc例程循环n_iterations/n_threads次。
每个线程都使用THR_BOUND和THR_SUSPENDED标志来派生。THR_BOUND通知Solaris线程运行时库将该线程绑定到专用的LWP。每个LWP可以在一个多处理器系统中的单独的PE上并行运行。THR_SUSPENDED标志创建“挂起”状态的线程,确保在调用thr_continue恢复(resume)测试之前,所有线程被完全地初始化。thr_continue函数是一个Solaris线程库例程,可恢复挂起线程的执行。注意此例利用了Solaris是以升序连续分配线程id的事实。
一旦所有线程都已被恢复,thr_join例程就阻塞主线程的执行。thr_join与UNIX wait系统调用相类似棗它获取退出线程的状态。thr_join将“收割”线程,并返回0,直到运行run_svc的线程全部退出为止。当所有其他的线程退出后,主线程打印出iterations的总数,以及request_count的最后值,然后退出程序。
将此代码编译成可执行的a.out文件,并以1 个线程循环10,000,000次的方式运行它,得到如下结果:
% a.out 1
10000000
thread id = 4,
status = 1000000
10000000 =
iterations
10000000 =
request count
该结果正如所愿。但是,当以4个线程循环10,000,000次的方式在4个PE的机器上运行时,程序打印出:
% a.out 4
10000000
thread id = 5,
status = 1000000
thread id = 7,
status = 1000000
thread id = 6,
status = 1000000
thread id = 4,
status = 1000000
10000000 =
iterations
5000000 =
request count
显然,有什么出错了,因为全局变量request_count的值只是循环总数的一半。这里的问题是变量request_count的自增没有被正确地序列化。
一般而言,在不提供“强有序缓存一致性模型”(strong sequential order cache consistency model)的共享内存多处理器平台上并行执行时,run_svc会产生不正确的结果。为增强性能,许多共享内存多处理器采用“弱有序缓存一致性语义”(weakly-ordered cache consistency semantics)。例如,SPARC多处理器的V.8和V.9家族同时提供“总存储序”(total
store order)和“部分存储序”(partial store order)内存缓存一致性语义。对于总存储序语义,对正在被其他PE上的线程访问的变量的读取和同时进行的对同一变量的写也许不会被序列化。同样地,对于部分存储序语义,写操作与写操作也可能不会被序列化。在任一情形中,由于多PE间的缓存延迟,需要对内存位置进行不止一次装载和存储的表达式(比如foo++或i = i – 10)可能会产生不一致的结果。为确保线程间共享的变量的读写被正确更新,程序员必须人工地保证对这些变量的变动成为全局可见的。
在“总存储序”或“部分存储序”共享内存多处理器上强制实现强有序的一种常用技术是使用同步机制来保护request_count变量的增长。Solaris 2.x提供若干种同步机制。本论文描述Solaris 2.x提供的四种主要同步机制的C++包装:互斥体、读者/作者锁、计数信号量,以及条件变量[19]。ACE含有封装这四种Solaris 2.x同步机制(分别为mutex_t、rwlock_t、sema_t,以及