第5章 C/C++线程专有存储(Thread-Specific Storage):用于访问“per-Thread”状态的对象行为模式
Douglas C. Schmidt Nat Pryce Timothy H. Harrison
在理论上,使应用多线程化可以改善性能(通过同时执行多个指令流),并简化程序结构(通过允许每个线程同步地、而不是反应式地或异步地执行)。而在实践中,由于获取和释放锁的开销,多线程应用常常并不比单线程应用执行得更好,甚至还会更糟。此外,由于避免条件竞争和死锁所需的复杂的并发控制协议,多线程应用常常难以编程。
本论文描述线程专有存储(Thread-Specific Storage)模式,该模式可减轻多线程性能和编程复杂性的若干问题。通过允许多个线程使用一个逻辑上的全局访问点来获取线程专有数据,而又不给每次访问带来锁定开销,线程专有存储模式可改善性能,并简化多线程应用。
允许多个线程使用一个逻辑上的全局访问点来获取线程专有数据,而又不给每次访问带来锁定开销。
线程专有存储应被用于这样的多线程应用:它们经常访问那些逻辑上是全局的、而物理上是专有于每个线程的对象。例如,像UNIX和Win32这样的操作系统使用errno来向应用报告错误信息。当错误在系统调用中发生时,OS设置errno来报告问题、并返回文档化的失败状态。当应用检测到失败时,它检查errno来确定发生了何种类型的错误。
例如,考虑下面典型的C代码片段,它接收来自非阻塞TCP socket的缓冲区:
// One global
errno per-process.
extern int
errno;
void *worker
(SOCKET socket)
{
// Read from
the network connection
// and process
the data until the connection
// is closed.
for (;;)
{
char buffer[BUFSIZ];
int result = recv (socket, buffer,
BUFSIZ, 0);
// Check to see if the recv() call
failed.
if (result == -1)
{
if (errno != EWOULDBLOCK)
// Record error result in
thread-specific data.
printf ("recv failed, errno =
%d", errno);
}
else
// Perform the work on success.
process_buffer (buffer);
}
}
如果recv返回-1,代码检查errno是否等于 EWOULDBLOCK,如果不是(例如,如果errno = EINTR)就打印出错误消息;返回的不是-1代码就处理它接收到的缓冲区。
尽管上面所示的“全局错误变量”方法对于单线程应用工作得相当好,在多线程应用中却会发生微妙的问题。特别地,占先式多线程系统中的条件竞争会导致一个线程中的方法所设置的errno值被另一线程中的应用错误地解释。因而,如果多个线程同时执行worker函数,全局版本的errno就有可能会由于条件竞争而被不正确地设置。
例如,在图5-1中两个线程(T1和T2)可以在socket上执行recv调用。在此例中,T1的recv返回-1,并设置errno为EWOULDBLOCK,指示目前没有数据在socket上排队。但是在它检查这种情况之前,T1被占先,T2运行。假设T2被中断,它设置errno为EINTR。如果T2随之又立即被占先,T1将会错误地假定它的recv调用被中断,并执行错误的动作。因而,这个程序是错误的和不可移植的,因为它的行为依赖于线程执行的顺序。
在这之下的问题是对全局errno值的设置和测试发生在两个步骤中:(1)recv调用设置该值和(2)应用测试该值。因此,“显而易见”的解决方案,即用互斥体包装errno并不能解决竞争状态,因为设置/测试涉及到多个操作(也就是,它不是原子的)。
解决此问题的一种途径是创建更为成熟的锁定协议。例如,recv调用可以在内部获取errno_mutex,并且必须由应用在recv返回、errno的值被测试后来释放它。但是,此方案并不合乎需要,因为应用可能会忘记释放锁,从而导致饥饿和死锁。而且,如果应用必须在每次库调用后检查错误状态,额外的锁定开销将会显著地降低性能,即使在没有使用多个线程的情况下也是如此。

图5-1 多线程程序中的条件竞争
上面描述的陷阱和缺陷的一种通用解决方案是使用线程专有存储模式。该模式消除以下压力:
因此,不管应用是运行在单线程还是多线程中,使用线程专有存储模式都不会带来额外开销,并且无需改动代码。例如,下面的代码演示errno是如何在Solaris 2.x上定义的:
// A
thread-specific errno definition (typically
// defined in
<sys/errno.h>).
#if defined
(_REENTRANT)
// The _errno()
function returns the
//
thread-specific value of errno.
#define errno (*_errno())
#else
// Non-MT
behavior is unchanged.
extern int
errno;
#endif /*
REENTRANT */
void *worker
(SOCKET socket)
{
// Exactly the
same implementation shown above.
}
如果_REENTRANT标志被设置,errno符号就被定义为调用名为_errno的助手函数的宏,此函数返回一个指向errno的线程专有值的指针。该指针被宏去除引用,以使它能够任意出现在赋值运算的左边或右边。
应用有以下特性时可使用线程专有存储:
理解上面描述的特性对于使用(或不使用)线程专有存储模式来说是至关紧要的。例如,UNIX errno变量是一个数据例子:(1)逻辑上全局,但是物理上线程专有,以及(2)在方法间隐式地传递。
当应用有以下特性时,不要使用线程专有存储模式:
图5-2演示线程专有存储中的以下参与者的结构:
应用线程(Application
Thread)

图5-2 线程专有存储模式中的参与者的结构
线程专有对象代理(TS
Object Proxy)(errno宏)
线程专有对象(TS
Object)(* _errno()value)
线程专有对象集合(TS
Object Collection)
图5-3中的交互图演示线程专有存储模式中的参与者之间的以下协作:

图5-3 线程专有存储模式中的参与者之间的交互
使用线程专有存储有若干好处,包括:
效率:线程专有存储可实现成无需对线程专有数据进行锁定。例如,通过将errno放入线程专有存储中,每个线程都可以可靠地设置和测试该线程中的方法的完成状态,而无需使用复杂的同步协议。这排除了线程中共享数据的锁定开销,比起获取和释放互斥体要更为迅捷[1]。
易于使用:对于应用程序员来说,线程专有存储使用起来很简单,因为系统开发者可以通过数据抽象或宏来使线程专有存储的使用在源码级完全透明化。
使用线程专有存储还有着以下缺点:
它鼓励了(线程安全的)全局变量的使用:许多应用不要求多个线程通过公用访问点来访问线程专有的数据。如果是这样,数据的存储应使只有拥有该数据的线程可对它进行访问。例如,考虑一个网络服务器,它使用工作者线程池来处理来自客户的请求。这些线程可能会记录所执行服务的数量和类型。这个日志机制可以作为使用线程专有存储的全局Logger对象来访问。但是,更简单的方法是将每个工作者线程表示为主动对象[2],并在其内部存储Logger的实例。在这样的情况下,只要将Logger作为参数传递给主动对象中的所有函数,对Logger的访问就不会产生额外开销。
它隐藏了系统的结构:线程专有存储的使用隐藏了应用中的对象之间的关系,可能会导致应用更难被理解。如附录A.2所描述的,在某些情况下,显式地表现对象间的关系可以消除对线程专有存储的需要。
线程专有存储模式可以通过多种途径来实现。这一部分解释实现该模式所需的每一步骤。这些步骤被总结如下:
这一部分的余下部分描述怎样实现低级的线程专有存储API。5.8提供了完整的示例代码,5.9检查通过C++包装来封装低级的线程专有存储API的若干方法。
图5-2中所示的TS Object Collection含有所有属于某个特定线程的TS Object。该集合可以使用指向TS Object的指针表来实现,这些对象通过key进行索引。线程必须通过key来在访问线程专有对象之前定位TS Object Collection。因此,第一个设计挑战就是决定怎样定位和存储TS Object Collection。
TS对象集合可通过以下两种方式存储:(1)外在于所有线程,或(2)内在于每一线程。对每种方法的描述和评估如下:

图5-4 线程专有存储的外部实现
在外部和内部实现方案之间选择需要开发者进行以下权衡:
定长 vs. 变长TS Object Collection:对于外部和内部两种实现,如果线程专有钥的范围相对较小,TS Object Collection可作为定长数组存储。例如,POSIX Pthread标准定义了专有钥的最小数目_POSIX_THREAD_KEYS_MAX,遵从标准的实现必须对其加以支持。如图5-5所示,如果长度是固定的(例如,128个专有钥,这是POSIX的缺省值),通过简单地使用对象的专有钥来在TS Object Collection数组中检索,查找时间可为O(1)。
但是,线程专有钥的范围也可以很大。例如,Solaris线程对专有钥的数目没有预定义的限制。因此,Solaris使用了一种变长数据结构,这可能会增加管理TS Object Collection所需的时间。
定长 vs. 变长的线程ID到TS Object Collection的映射:线程ID的范围可以从很小到很大的值。这对于内部实现来说没有任何问题,因为线程ID隐含地与相应的包含在线程状态中的TS Object Collection关联在一起。
但是,对于外部实现,要使每个可能的线程ID值都在定长数组中有相应条目可能是不现实的。相反,让线程使用一个动态数据结构来将线程ID映射到TS Object Collection,在空间上要更为经济。例如,一种方法是在线程ID上使用哈希函数,以获得在哈希表桶中的一个偏移;在哈希表桶中含有一系列二元组,将线程ID映射到它们相应的TS Object Collection(如图5-4所示)。
全局 vs. 局部TS对象集合:内部方法局部地存储TS Object Collection,而外部方法全局地存储它们。取决于外部表的实现,全局定位可允许线程访问其他线程的TS Object Collection。尽管这看起来废止了线程专有存储的整个出发点,如果线程专有存储实现通过回收使用无用的专有钥来提供自动的垃圾收集的话,这还是有用的。该特性对于限制专有钥数目为较小值的实现特别重要(例如,Windows NT有着每个进程64个专有钥的限制)。
但是,使用外部表增加了每个线程专有对象的访问时间,因为如果可全局访问的表被修改的话(例如,创建新专有钥时),需要使用同步机制(比如读者/作者锁)来避免竞争状态。在另一方面,将TS Object Collection局部地保持在每个线程的状态中需要每个线程有更多的存储,而总的内存消耗也不会减少。

图5-5 线程专有存储的内部实现
5.8.1
实现POSIX Pthreads线程专有存储API
下面的代码演示在使用专有钥定长数组将TS Object“内部地”存储在线程中时,线程专有存储是怎样实现的。这个例子是从POSIX Pthreads[4]的一个公开可用的实现[3]改编而来。
下面所示的thread_state结构含有线程的状态:
struct
thread_state
{
// The
thread-specific error number.
int errno_;
//
Thread-specific data values.
void
*key_[_POSIX_THREAD_KEYS_MAX];
// ... Other
thread state.
};
除了errno和线程专有存储指针的数组,该结构还包括了一个指向线程的栈和空间的指针;这些栈和空间用于存储在上下文切换过程中存储/恢复的数据(例如,程序计数器)。
对于一个特定的线程专有对象,所有线程用同一个专有钥值来设置或取回线程专有的值。例如,如果Logger对象正在被登记以跟踪线程专有的日志属性,线程专有的Logger代理将分配得到某个专有钥值N。所有线程都将使用这个值N来访问它们的线程专有日志对象。目前正在使用的专有钥的总数目可相对于所有线程全局地存储。如下所示:
typedef int
pthread_key_t;
// All threads
share the same key counter.
static
pthread_key_t total_keys_ = 0;
每次有新的线程专有钥被请求时,total_keys_count都会自动增长,如下面的pthread_key_create函数所示:
// Create a new
global key and specify
// a
"destructor" function callback.
Int
pthread_key_create (pthread_key_t *key,
void
(*thread_exit_hook) (void *))
{
if (total_keys_
>= _POSIX_THREAD_KEYS_MAX)
{
// pthread_self() refers to the
context of the
// currently active thread.
pthread_self ()->errno_ =
ENOMEM;
return -1;
}
thread_exit_hook_[total_keys_]
= thread_exit_hook;
*key =
total_keys_++;
return 0;
}
pthread_key_create函数分配一个新的专有钥值,唯一地标识一个线程专有数据对象。此外,它还允许应用将一个thread_exit_hook与一个专有钥关联。该挂钩(hook)是一个函数指针,会被自动调用,当(1)线程退出时,以及(2)有线程专有对象登记专有钥时。指向“线程退出挂钩”的函数指针的数组可被全局地存储。如下所示:
// Exit hooks
to cleanup thread-specific keys.
static void
(*thread_exit_hook_[_POSIX_THREAD_KEYS_MAX]) (void);
下面的pthread_exit函数演示线程退出挂钩函数是怎样在pthread_exit的实现中被调用的:
// Terminate
the thread and call thread exit hooks.
void
pthread_exit (void *status)
{
// ...
for (i = 0; i
< total_keys; i++)
if (pthread_self ()->key_[i]
&& thread_exit_hook_[i])
// Indirect pointer to function
call.
(*thread_exit_hook_[i])
(pthread_self ()->key_[i]);
// ...
}
应用可为各个线程专有数据对象登记不同的函数,但是对于每个对象、会为每个线程调用同一个函数。登记动态分配的线程专有存储对象是一种常见的使用方法。因此,线程退出挂钩通常看起来是这样的:
static void
cleanup_tss_Logger (void *ptr)
{
// This cast is
necessary to invoke
// the
destructor (if it exists).
delete (Logger
*) ptr;
}
该函数释放一个动态分配的Logger对象。
pthread_setspecific函数为调用线程将value绑定到给定的key:
// Associate a
value with a data key
// for the
calling thread.
int
pthread_setspecific (int key, void *value)
{
if (key < 0
|| key >= total_keys) {
pthread_self ()->errno_ =
EINVAL;
return -1;
}
pthread_self
()->key_[key] = value;
return 0;
}
同样地,pthread_getspecific为调用线程把绑定到给定Key的数据存储到value中:
// Retrieve a
value from a data key
// for the
calling thread.
int
pthread_getspecific (int key, void **value)
{
if (key < 0
|| key >= total_keys)
{
pthread_self ()->errno_ =
EINVAL;
return -1;
}
*value =
pthread_self ()->key_[key];
return 0;
}
因为数据存储在每个线程的内部状态中,这些函数不需要任何额外的锁来访问线程专有数据。
下面的例子演示怎样在可从多于一个线程调用的一个C函数中,使用来自POSIX Pthread规范的线程专有存储API,而又无须显式地调用初始化函数:
// Local to the
implementation.
static
pthread_mutex_t keylock = PTHREAD_MUTEX_INITIALIZER;
static
pthread_key_t key;
static int once
= 0;
void *func
(void)
{
void *ptr = 0;
// Use the
Double-Checked Locking pattern
// (described
further below) to serialize
// key creation
without forcing each access
// to be
locked.
if (once == 0)
{
pthread_mutex_lock (&keylock);
if (once == 0)
{
// Register the free(3C) function
// to deallocation TSS memory when
// the thread goes out of scope.
pthread_key_create (&key,
free);
once = 1;
}
pthread_mutex_unlock
(&keylock);
}
pthread_getspecific
(key, (void **) &ptr);
if (ptr == 0)
{
ptr = malloc (SIZE);
pthread_setspecific (key, ptr);
}
return ptr;
}
上面的解决方案直接在应用代码中调用线程专有库函数(比如pthread_getspecific和pthread_setspecific)。但是这些直接用C编写的API有以下局限:
5.8演示了怎样通过POSIX pthread接口实现和使用线程专有存储模式。但是,所得到的解决方案不可移植、难以使用,且不是类型安全的。为了克服这些局限,可以开发另外的类和C++包装来健壮地以一种类型安全的方式编写线程专有存储。
这一部分演示怎样使用C++包装来封装POSIX Pthreads、Solaris或Win32线程提供的低级线程专有存储机制。5.9.1描述怎样通过硬编码的C++包装来封装POSIX Pthread库接口,5.9.2描述一种使用C++模板包装的更为通用的解决方案。用于每种可选方法的例子是5.6.2描述的Logger抽象的一个变种。
使一个类的所有实例成为线程专有的一种方法是直接使用线程专有库例程。实现该方法所需的步骤描述如下。错误检查已被省略到最少以节约空间。
第一步是决定必须在线程专有存储中存取的对象状态信息。例如,Logger可能有以下状态:
class
Logger_State
{
public:
int errno_;
// Error
number.
int line_num_;
// Line where
the error occurred.
// ...
};
每个线程都将拥有它自己的一份这些状态信息的拷贝。
下一步是定义被所有应用线程使用的外部类接口。下面的Logger外部类接口看起来就像是一个平常的非线程专有的C++类:
class Logger
{
public:
// Set/get the
error number.
int errno
(void);
void errno
(int);
// Set/get the
line number.
int line_num
(void);
void line_num
(int);
// ...
};
该步骤使用线程库提供的线程专有存储函数来定义一个助手函数,它返回一个指向适当的线程专有存储的指针。这个助手函数通常执行以下步骤:
class Logger
{
public:
// ... Same as above ...
protected:
Logger_State *get_tss_state (void);
// Key for the thread-specific error data.
pthread_key_t key_;
// "First time in" flag.
int once_;
};
Logger_State *Logger::get_tss_state (void)
{
// Check to see if this is the first time in
// and if so, allocate the key (this code
// doesn’t protect against multi-threaded
// race conditions...).
if (once_ == 0)
{
pthread_key_create (this->key_, free);
once_ = 1;
}
Logger_State *state_ptr;
// Get the state data from thread-specific
// storage. Note that no locks are required...
pthread_getspecific (this->key_, (void **)
&state_ptr);
if (state_ptr == 0)
{
state_ptr = new Logger_State;
pthread_setspecific (this->key_, (void *) state_ptr);
}
// Return the pointer to thread-specific storage.
return state_ptr;
};
int Logger::errno (void)
{
return this->get_tss_state ()->errno_;
}
Logger logger;
int recv_msg
(HANDLE socket, char *buffer, size_t bufsiz)
{
if (recv
(socket, buffer, bufsiz, 0) == -1)
{
logger->errno () = errno;
return -1;
}
// ...
}
int main (void)
{
// ...
if (recv_msg
(socket, buffer, BUFSIZ) == -1
&& logger->errno () ==
EWOULDBLOCK)
// ...
}
使用硬编码包装的优点是它将应用与线程专有库函数的知识屏蔽开来。该方法的缺点是它不能促进复用性、可移植性,或是灵活性。特别地,对于每个线程专有类,开发者都需要在类中重新实现线程专有助手方法。
而且,如果应用被移植到有着不同的线程专有存储API的平台上,就必须改变在每个线程专有类中的代码,以使用新的线程库。此外,直接改变线程专有类使得程序难以变更线程策略。例如,将一个线程专有类变为全局类需要对代码进行侵入性的变动,从而降低了灵活性和复用性。特别地,每次对对象内部的状态信息的访问都将要求改变用于从线程专有存储取回该状态的助手方法。
一种更为可复用、可移植和灵活的方法是实现TS Object Proxy模板,负责所有的线程专有方法。该方法允许类与线程专有存储怎样实现的知识去耦合。通过定义称为TSS的代理类,该解决方案改善了代码的可复用性、可移植性和灵活性。如下所示,该类是一个模板,通过其对象驻留在线程专有存储中的类来参数化:
// TS Proxy
template
template
<class TYPE>
class TSS
{
public:
// Constructor.
TSS (void);
// Destructor
?TSS (void);
// Use the C++
"smart pointer" operator to
// access the
thread-specific TYPE object.
TYPE *operator->
();
private:
// Key for the
thread-specific error data.
pthread_key_t
key_;
// "First
time in" flag.
int once_;
// Avoid race
conditions during initialization.
Thread_Mutex
keylock_;
// Cleanup hook
that deletes dynamically
// allocated
memory.
static void
cleanup_hook (void *ptr);
};
该类中的方法描述如下。和前面一样,错误检查已被省略到最少以节约空间。
5.9.2.1 C++委托操作符(Delegation Operator)
通过重载C++委托操作符(操作符->),应用可以调用TSS代理上的方法,就好像是在调用目标类一样。在此实现中使用的C++委托操作符控制所有对类TYPE的线程专有对象的访问。操作符->方法受到了来自C++编译器的特殊对待。如5.9.2.3所述,它先从线程专有存储那里获取一个指向适当的TYPE的指针,随后就重新委托原来在其上调用的方法。
TSS类中的大多数工作都在下面所示的操作符->方法中执行:
template
<class TYPE> TYPE *
TSS<TYPE>::operator->
()
{
TYPE *tss_data
= 0;
// Use the
Double-Checked Locking pattern to
// avoid
locking except during initialization.
// First check.
if
(this->once_ == 0)
{
// Ensure that we are serialized
(constructor
// of Guard acquires the lock).