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 线程管理
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 * 。这一特点与进程的返回值一致,都是使用整数表示一个任务的结束状态。
thrd_t thrd_current(void);
thrd_current 函数返回调用线程的标识符。类似于 pthreads 下的 pthread_self() 函数。
int thrd_detach(thrd_t thr);
thrd_detach 知会操作系统,当该线程结束时,操作系统负责回收该线程所占用的资源。
int thrd_equal(thrd_t thr0, thrd_t thr1);
thrd_equal 用于判断两个线程标识符是否相等(即标识同一线程), thrd_t 是标准约定的类型,可能是一个基础类型,也可能会是结构体,开发人员应该使用 thrd_equal 来判断两者是否相等,不能直接使用 == 。即使 == 在某个平台下表现出来是正确的,但它不是标准的做法,也不可跨平台。
void thrd_exit(int res)
thrd_exit 函数提早结束当前线程, res 是它的退出状态码。这与进程中的 exit 函数类似。
int thrd_join(thrd_t thr, int *res)
thrd_join 将阻塞当前线程,直到线程 thr 结束时才返回。如果 res 非空,那么 res 将保存 thr 线程的结束状态码。如果某一线程内没有调用 thrd_detach 函数将自己设置为 detach 状态,那么当它结束时必须由另外一个线程调用 thrd_join 函数将它留下的僵死状态变为结束,并回收它所占用的系统资源。
void thrd_sleep(const xtime *xt)
thrd_sleep 函数让当前线程中途休眠,直到由 xt 指定的时间过去后才醒过来。
void thrd_yield(void)
thrd_yield 函数让出 CPU 给其它线程或进程。
3.2 互斥对象和函数
C1X threading 中提供了丰富的互斥对象,用户只需 mtx_init 初始化时,指定该互斥对象的类型即可,如递归的,支持 timeout 和,或者支持锁检测。
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 – 支持锁检测的递归互斥对象
int mtx_lock(mtx_t *mtx) int mtx_timedlock(mtx_t *mtx, const xtime *xt) int mtx_trylock(mtx_t *mtx)
mtx_xxxlock 函数对 mtx 互斥对象进行加锁 , 它们会阻塞,直到获取锁,或者 xt 指定的时间已过去。而 trylock 版本会进行锁检测,如果该锁已被其它线程占用,那么它马上返回 thrd_busy 。
int mtx_unlock(mtx_t *mtx)
mtx_unlock 对互斥对象 mtx 进行解锁。
3.3 条件变量
C1X 中的条件变量与 pthreads 中的条件变量是一样的, C1X 通过 mtx 对象和条件变量来实现 wait-notify 机制,这与 Java 语言里 Object 对象中的 wait() 和 notify() 方法类似。
int cnd_init(cnd_t *cond)
初始化条件变量,所有条件变量必须初始化后才能使用。
int cnd_wait(cnd_t *cond, mtx_t *mtx) 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 互斥对象。
int cnd_signal(cnd_t *cond) int cnd_broadcast(cnd_t *cond)
cnd_broadcast 函数用于唤醒那些当前已经阻塞在 cond 条件变量上的所有线程,而 cnd_signal 只唤醒其中之一。
void cnd_destroy(cnd_t *cond)
cnd_destroy函数用于销毁条件变量。
3.4 初始化函数
试想一下,如何在一个多线程同时执行的环境下来初始化一个变量,即著名的延迟初始化单例模式。你可能会使用 DCL 技术。但在 C1X threading 环境下,你可以直接使用 call_once 函来实现。
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 提供的标准函数如下:
int tss_create(tss_t *key, tss_dtor_t dtor) void tss_delete(tss_t key) void *tss_get(tss_t key) 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 之抉择等。
文章转自 并发编程网-ifeve.com