感谢林永听投递本文。 校对:方腾飞
1. 关于 C1X 标准
C1X 是 C 语言的下一个标准,用于取代现有的 C99 标准。 C1X 是一个非正式名字,该标准仍在制订中,最新的工作草案是 N1494 ,发布于 2010 年 6 月。与 C99 相比, C1X 在语言和库上有显著的变化,本文重点分析 N1494 草案中的多线程部分。
2. 呼之欲出的多线程
不瞒你说, C99 标准里面的内存模型仍然是单线程的,即所有代码都运行在一个线程(进程)内。也许,你简直不敢相信这个是真的,因为说不定你每天都与多线程打交道, 使用 _beginthread , CreateThread 或 pthread_create 等不同平台的函数去创建线程。当你担心每个变量被编译器优化时,不得不加上 volatile 修饰符时,或者加上内存栅栏 (memory barrier) ,都印证了 C99 为单线程的内存模型。
32 位保护模式的出现,催生了多任务操作系统的诞生,随之而来的就是多线程环境。随着多核时代的到来,多线程环境成为程序开发不可逃避的问题。无锁编程,并行编程等已经成熟的技术在 C 社区里,常常给初学者遥不可及的感觉。在迫切需要多线程的时代,不同厂商和平台都纷纷开发了各自的多线程库,如 POSIX 的 pthreads 和 window 的 winThread 。软件工程师在特定的平台下,使用各自的线程库来开发多线程或并发程序,并非很难,但可移植却成了他们面临的问题。如 pthreads 在默认情况下,互斥锁是非递归的,要使用递归互斥锁,程序库必须支持一些扩展 feature ;而 window 下的锁默认情况下是递归的。 lock-wait-wakup 方式也不尽然相同,这对开发跨平台的多线程程序来说无异于雪上加霜。
放眼 C 语言的后来者,如 Java ,很早就支持了多线程,并且它的内存模型在不断地修改,以适应发挥多线程的优势。 Erlang 作为一种天生的并发编程语言,踏上编程语言的行列,成为并发编程的新星。同时,很多脚本语都内置地支持线程类或结构。 C/C++ 作为后来加入到多线程的行列,尽管已经太晚了,但对于 C 语言社区来说,这无疑是最令人兴奋的消息了。
3. C1X 多线程接口与语义
如果你有 pthreads 的开发经验,应该对 C1X threading 不会感到陌生,因为它们在 API ,参数和语义方面都惊人地一致,以致于你学会了 pthreads ,基本就学会了大半个 C1X threading 了。相反,对于 winthread 的开发人员来说,需要换一个角度来看等多线程,特别是 cnd_wait 和 cnd_signal 之间的 time race 关系。好吧,让我们一览目前最新的 C1X threading 编程吧。
3.1 线程管理
1 |
int thrd_create(thrd_t *thr, thrd_start_t func, void *arg); |
thrd_create 创建一个新线程,该线程的工作就是执行 func(arg) 调用,程序员需要为线程编写一个函数,函数签名为: thrd_start_t ,即 int (*)(void*) 类型的函数。新创建的线程的标识符存放在 thr 内。
与 pthread_create 函数相比, thrd_create 函数没有线程属性这一参数,并具线程函数的返回值是 int ,而非 pthreads 的 void * 。这一特点与进程的返回值一致,都是使用整数表示一个任务的结束状态。
1 |
thrd_t thrd_current( void ); |
thrd_current 函数返回调用线程的标识符。类似于 pthreads 下的 pthread_self() 函数。
1 |
int thrd_detach(thrd_t thr); |
thrd_detach 知会操作系统,当该线程结束时,操作系统负责回收该线程所占用的资源。
1 |
int thrd_equal(thrd_t thr0, thrd_t thr1); |
thrd_equal 用于判断两个线程标识符是否相等(即标识同一线程), thrd_t 是标准约定的类型,可能是一个基础类型,也可能会是结构体,开发人员应该使用 thrd_equal 来判断两者是否相等,不能直接使用 == 。即使 == 在某个平台下表现出来是正确的,但它不是标准的做法,也不可跨平台。
1 |
void thrd_exit( int res) |
thrd_exit 函数提早结束当前线程, res 是它的退出状态码。这与进程中的 exit 函数类似。
1 |
int thrd_join(thrd_t thr, int *res) |
thrd_join 将阻塞当前线程,直到线程 thr 结束时才返回。如果 res 非空,那么 res 将保存 thr 线程的结束状态码。如果某一线程内没有调用 thrd_detach 函数将自己设置为 detach 状态,那么当它结束时必须由另外一个线程调用 thrd_join 函数将它留下的僵死状态变为结束,并回收它所占用的系统资源。
1 |
void thrd_sleep( const xtime *xt) |
thrd_sleep 函数让当前线程中途休眠,直到由 xt 指定的时间过去后才醒过来。
1 |
void thrd_yield( void ) |
thrd_yield 函数让出 CPU 给其它线程或进程。
3.2 互斥对象和函数
C1X threading 中提供了丰富的互斥对象,用户只需 mtx_init 初始化时,指定该互斥对象的类型即可,如递归的,支持 timeout 和,或者支持锁检测。
1 |
int mtx_int(mtx_t *mtx, int type); |
mtx_init 函数用于初始化互斥对象, type 决定互斥对象的类型,一共有下面 6 种类型:
- mtx_plain — 简单的,非递归互斥对象
- mtx_timed — 非递归的,支持超时的互斥对象
- mtx_try — 非递归的,支持锁检测的互斥对象
- mtx_plain | mtx_recursive — 简单的,递归互斥对象
- mtx_timed | mtx_recursive — 支持超时的递归互斥对象
- mtx_try | mtx_recursive – 支持锁检测的递归互斥对象
1 |
int mtx_lock(mtx_t *mtx) |
2 |
3 |
int mtx_timedlock(mtx_t *mtx, const xtime *xt) |
4 |
5 |
int mtx_trylock(mtx_t *mtx) |
mtx_xxxlock 函数对 mtx 互斥对象进行加锁 , 它们会阻塞,直到获取锁,或者 xt 指定的时间已过去。而 trylock 版本会进行锁检测,如果该锁已被其它线程占用,那么它马上返回 thrd_busy 。
1 |
int mtx_unlock(mtx_t *mtx) |
mtx_unlock 对互斥对象 mtx 进行解锁。
3.3 条件变量
C1X 中的条件变量与 pthreads 中的条件变量是一样的, C1X 通过 mtx 对象和条件变量来实现 wait-notify 机制,这与 Java 语言里 Object 对象中的 wait() 和 notify() 方法类似。
1 |
int cnd_init(cnd_t *cond) |
初始化条件变量,所有条件变量必须初始化后才能使用。
1 |
int cnd_wait(cnd_t *cond, mtx_t *mtx) |
2 |
3 |
int cnd_timedwait(cnd_t *cond, mtx_t *mtx, const xtime *xt) |
cnd_wait 函数自动对 mtx 互斥对象进行解锁操作,然后阻塞,直到条件变量 cond 被 cnd_signal 或 cnd_broadcast 调用唤醒,当前线程变为非阻塞时,它将在返回之前锁住 mtx 互斥对象。 cnd_timedwait 函数与 cnd_wait 类似,例外之处是当前线程在 xt 时间点上还未能被唤醒时,它将返回,此时返回值为 thrd_timeout 。 cnd_wait 和 cnd_timedwait 函数在被调用前,当前线程必须锁住 mtx 互斥对象。
1 |
int cnd_signal(cnd_t *cond) |
2 |
int cnd_broadcast(cnd_t *cond) |
cnd_broadcast 函数用于唤醒那些当前已经阻塞在 cond 条件变量上的所有线程,而 cnd_signal 只唤醒其中之一。
1 |
void cnd_destroy(cnd_t *cond) |
cnd_destroy函数用于销毁条件变量。
3.4 初始化函数
试想一下,如何在一个多线程同时执行的环境下来初始化一个变量,即著名的延迟初始化单例模式。你可能会使用 DCL 技术。但在 C1X threading 环境下,你可以直接使用 call_once 函来实现。
1 |
void call_once(once_flag *flag, void (*func)( void )) |
call_once 函数使用 flag 来保确 func 只被调用一次。第一个线程使用 flag 去调用 call_once 时,函数 func 会被调用,而接下来的使用相同 flag 来调用的 call_once , func 均不会再次被调用,以保正 func 在多线程环境只被调用一次。
3.5 线程专有数据 (thread-specific data, TSD) 和线程局部数据 (thread-local storage, TLS)
在多线程开发中,并不是所有的同步都需要加锁的,有时巧妙的数据分解也可减少锁的碰撞。每个线程都拥有自己私有数据,使用它可以减少线程间共享数据之间的同步开销。
如果要将一些遗留代码进行线程化,很多函数都使用了全局变量,而在多线程环下,最好的方法可能是将这些全局量变量换成线程私有的全局变量即可。
TSD 和 TLS 就是专门用来处理线程私有数据的。 它的生存周期是整个线程的生存周期,但它在每个线程都有一份拷贝,每个线程只能 read-write-update 属于自己的那份。如果通过指针方式来 read-write-update 其它线程的备份,它的行为是未定义的。
C1X 同时提供了 TSD 和 TLS 特性,而 pthreads 只提供 TSD ,但在 linux 下的 gcc 编译器提供了 TLS 作为扩展特性。 TSD 可认为线程私有内存下的 void * 组数,每个数据项的 key 对应于数组的下标,用于索引功能。当一个新线程创建时,线程的 TSD 区域将所有 key 关联的值设置为 NULL 。 TSD 是通过函数的方式来操作的。 C1X 中 TSD 提供的标准函数如下:
1 |
int tss_create(tss_t *key, tss_dtor_t dtor) |
2 |
void tss_delete(tss_t key) |
3 |
void *tss_get(tss_t key) |
4 |
int tss_set(tss_t key, void *val) |
tss_create 函数创建一个 key , dtor 为该 key 将要关联 value 的析构函数。当线程退出时,会调用 dtor 函数来释放该 key 关联的 value 所占用的资源,当然,如果退出时 value 值为 NULL , dtor 将不被调用。 tss_delete 函数删除一个 key , tss_get/tss_set 分别获得或设置该 key 所关联的 value 。
通过上述 TSD 来操作线程私有变量的方式,显得相对繁琐 ; C1X 提供了 TLS 方法,可以像一般变量的方式去访问线程私有变量。做法很简单,在声明和定义线程私变量时指定 _Thread_local 存储修饰符即可,关于 _Thread_local , C1X 有如下的描述:
在声明式中,_Thread_local 只能单独使用,或者跟 static 或 extern 一起使用。
在某一区块( block scope) 中声明某一对象,如果声明存储修饰符有 _Thread_local ,那么必须同时有 static 或 extern 。
如果 _Thread_local 出现在一对象的某个声明式中,那么此对象的其余各处声明式都应该有 _Thread_local 存储修饰符。
如果某一对象的声明式中出现 _Thread_local 存储修饰符,那么它有线程储存期( thread storage duration )。该对象的生命周期为线程的整个执行周期,它在线程出生时创建,并在线程启动时初始化。每个线程均有一份该对象,使用声明时的名字即可引用正在执行当前表达式的线程所关联的那个对象。
TLS 方式与传统的全局变量或 static 变量的使用方式完全一致,不同的是, TLS 变量在不同的线程上均有各自的一份。线程访问 TLS 时不会产生 data race ,因为不需要任何加锁机制。 TLS 方式需要编译器的支持,对于任何 _Thread_local 变量,编译器要将之编译并生成放到各个线程的 private memory 区域,并且访问这些变量时,都要获得当前线程的信息,从而访问正确的物理对象,当然这一切都是在链接过程早已安排好的。
4. C1X threading 的未来
C1X threading 的整体设计与 pthreads 的惊人地一致,我甚至怀疑它们是出自一人(团队)之手。我最初接触多线程编译中的等待 – 通知原语是从 Java 中 Object 对象里的 wait 和 notify 函数中获得感性认识,然后在工作中将 pthreads 中的 pthread_cond_wait 和 pthread_cond_signal 函数应用于实际工作中,并解决了很多实际问题,如编写线程安全的数据结构。现在最新的 C1Xthreading 标准,线程等待 – 通知的方式与上述两者如同一辙。
与 pthreads 相比, C1X threading 没有了信号量操作,读写锁和自旋锁。显然开发人员可以借用 C1X threading 中提供的同步机制来实现信号量和读写锁,但要实现自旋锁是比较难,这需要深入了解所在平台和操作系统提供的原语,在此基础上再实现自旋锁。
对于 window 开发者来说, C1X threading 是一个新的标准,里面提供的同步原语与 winthread 相比,显得有点冷清。不过 window 下的开发人员通常都使用 C++ 或 MFC 提供的多线程库来开发,只是对于那些完全使用 C语言来开始跨平台多线程程序的同行来说,只能完全遵循 C1X threading 标准了。
C1X threading 以内存共享模型作为多线程编程模型,提供的同步机制基于锁来实现。将来是否会提供系统级别的,基于消息传递来实现无锁同步。
5. 总结
多线程和多核开发时代已悄悄降临在我们的身边,无论你现在的开发工作是否与并行开发相关, C1X threading 都应该成为你手中的一把利器。 C1X threading 了却你心中很多疑虑,可移植, TSD/TLS 之抉择等。