Linux Concurrent Programming —— Thread【Beta1-Updating】

来源:互联网 发布:霸屏软件 编辑:程序博客网 时间:2024/06/06 05:00

                                     Linux Concurrent Programming —— Thread


                                                                                                                                             ——by firo 2011.4.26

Reference

Google

man  7 pthreads

Professional Linux Kernel Architecture【PLKA】

Understanding The Linux Kernel 3e 【ULK 3e】

Computer Systems: A Programmer‘s Perspective 2e【CS:APP 2e】

Advanced Programming in the Unix Environment Second Edition 【APUE 2e】

Unix Network Programming The Sockets Networking API VOLUME 1 Third Edition【UNPv1 3e】

The Linux Programming inTerface A Linux and UNIX System Programming Handbook 【TLPI】

Thread Implementation Models——线程实现模型

What is the Kernel Scheduling Entities (KSE)?

Kernel Scheduling Entities内核调度实体。

“A kernel scheduling entity (KSE) is a “virtual CPU” granted to the process for the purpose of executing threads.  A thread that is currently executing is always associated with exactly one KSE, whether executing in user space or in the kernel.”(摘自Free BSD man手册)

KSE——虚拟的CPU,使得进程可以执行多个线程;正在执行的线程,无论它是在用户模式还是内核模式,它总是和一个确定的KSE相关联。

TLPI中关于KSE的描述:kernel分配CPU和其他系统资源给KSE。

Many-to-one (M:1) implementations (user-level threads)

关于线程的实现的所有操作(Many):线程创建,结束,Mutex同步,Condition variabled等等都由库(1)来实现。

优点:【1】因为不涉及System call,所有的操作也就非常的快啦~~【2】不需要内核支持,所以移植性非常高,除DOS那一辈的都行~

缺点:【1】你也不能总在用户模式执行吧,所以问题来了,当你执行一个System call时,好家伙,Context switch上下文切换到了kernel模式了,User-level的所有操作都Stop了,甭管你有多少个线程全停——谁叫你是User-level用户模式的,性能损耗不言自明。【2】kernel无法识别到这些用户级的Thread,所以kernel就不能切换他们了,分配他们倒不同的核上了(多核CPU),也无法调整线程的优先级,这些都由库来包办了,显然不是全局最优~~

One-to-one (1:1) implementations (kernel-level threads)

每一个Thread对应一个KSE,所有线程操作自然有内核提供了——System call。

优点:【1】不会出现User-level的一个系统调用阻塞掉所有的其他Thread。【2】实现了全局调度最优~

缺点:【1】因为都是System call 自然就慢下来,why?Context switch呀。【2】对于高性能的服务器来说,上百万的Thread同时对应着kernel需要维护上百万的KSE,这回带来性能损耗的。

Linux的线程实现就是这种~

Many-to-many (M:N) implementations (two-level model)

一个进程可以关联多个KSE,多个线程可以映射到一个KSE。

优点【1】见M:1和1:1模型【2】kernel交叉的分配线程给多核CPU。

缺点【1】见M:1和1:1模型【2】Complexity!调度和信息的传递同时涉及到Kernel-level和user-level Thread了~~

Linux Implementations of POSIX Threads——POSIX线程的Linux版实现

Next Generation POSIX Threads (NGPT)

基于M:N模型的linux线程库,由IBM开发。性能介于LinuxThreads和NPTL,03年终止开发了~~

Linux Threads

Linux的一代功臣,2.6之前的kernel使用的线程库,由Xavier Leroy开发的~~大致的实现过程是:通过clone系统调用,参数CLONE_VM | CLONE_FILES | CLONE_FS | CLONE_SIGHAND 分别用来指明共享创建者的VM虚拟内存(用户空间)、文件描述符、文件系统相关属性(umask,root directory等)、信号处理程序表和阻塞信号表和挂起信号表。kernel创建一个Manager Thread管理所有线程创建与结束等。实现内部使用real-time 实时Singal用来传递操作信息。另外,Linux Threads 与标准差异参见TLPI-33.5.1,这也是它被NPTL取代的原因。

 

NPTL (Native POSIX Threads Library)

Kernel2.6沿用至今,Linux Threads的继承者,有Ulrich Drepper 和 Ingo Molnar开发,这是他们的关于NPTL实现的论文草稿,他们供职于Redhat。NPTL性能远高于Linux Threads,并且更加符合Posix Thread标准。需要内核的改动支持,所以2.2与2.4的内核不支持NPTL。

NPTL实现的精要如下

【1】调用clone系统调用并指定参数(更详细的解说见ULK3e第三章):CLONE_VM | CLONE_FILES | CLONE_FS | CLONE_SIGHAND

| CLONE_THREAD(新线程被放在和创建者相同的Thread group 线程组并且拥有和创建者相同的PID进程ID和PPID父进程ID)

| CLONE_SETTLS(Thread Local Storage简称TLS线程局部存储段,通俗的说,就是用来指明创建存储线程私有数据的地方)

| CLONE_PARENT_SETTID(把TID线程ID存放在父进程的相关结构中)

| CLONE_CHILD_CLEARTID(清楚线程设置的数据)

| CLONE_SYSVSEM(共享System V IPC 取消信号量操作)

【2】使用头两个实时信号作为内部实现通信。

NPTL与标准不一致,月有圆缺嘛,相对与Linux Threads不一致的数量,NPTL还是非常少的:

Thread之间不能共享Nice值(类似优先级,Nice值越高CPU使用时间越长)

这么多的实现,你的Linux发行版上的ThreadLibary是哪一版?Shell命令试试~~

【1】getconf GNU_LIBPTHREAD_VERSION【2】$(ldd /bin/ls | grep libc.so | awk '{print $3}') | egrep -i 'threads|nptl' 第二个命令执行如下:ldd /bin/ls | grep libc.so 输出ls这个程序中关于Libc的信息,我的Ubuntu10.10如下:libc.so.6 => /lib/libc.so.6 (0x00d25000)之后通过Pipe传给awk 这是个正则表达式 输出第三个变量,这里就是:/lib/libc.so.6 之后由$将字符串中的命令提取并执行,再送到 egrep -i 'threads|nptl' 

 

Linux Thread Memory Module ——Linux线程内存模型

 

Linux 线程内存模型

本图来自TLPI-29.1节,版权归原作者Michael Kerrisk所有,在这里引用只为说明多线程内存模型,无商业目的~~

关于图片以及多线程栈的说明

需要特别说明下,Linux中有Main thread (一个进程有一个,就是进程最初创建的那个)和Peer-thread(可有多个)之分。Peer-thread在内存中如图所示和Shared Libraries共享库(Windows的DLL是也)以及Shared memory共享内存区一起都位于Heap 和Stack之间,但是,请注意TLPI中作者明确说明这三种之间顺序是由各自的创建时间决定的并不一定像图中那样都分出类来了,很多时候是交错的,例如,两个线程栈中间夹了个Shared memory。

Thread Stacks——线程栈

Peer-thread线程的栈大小是在创建之初决定的。Main-thread的Stack大小没有这个限制。在Linux/x86-32默认的Peer-thread大小为2M(我的Ubuntu10.10是8M),IA-64的大小为32M;我通过学长了解的淘宝服务器的内存大小为32G,以2M的栈大小计算能同时开启1.6万个线程;通过getconf得到最小栈的限制我的Ubuntu10.10显示如下:

 

 

最小的栈为16K,那么调用pthread_attr_setstacksize把栈减小16K的话,就能同时开启高达190万个线程了~~

关于NPTL库线程栈的大小限制的更改

在TLPI中作者指出:如果RLIMIT_STACK的值不是无限时,NPTL就将她作为创建新线程的默认栈大小;

NPTL是在程序运行之前确定这个默认值的,如果要更改这个值必须要在程序main函数运行之前通过Shell命令 ulimit -s,也就是说你不能通过调用setrlimit在Run-time时设置~~但是你可以决定你要创建的线程栈的大小,通过调用pthread_attr_setstacksize。

 

 

这里显示的默认的stack size 大小为8192Kb(8M,这个值和我在程序中调用pthread_attr_getstacksize得到的结果一致)不是上文的2M,我猜想原因是不同Linux发行版不一样吧,Ubuntu更改了那么多内核属性,也不差这一个~~言归正传:

 

现在设置为同getconf PTHREAD_STACK_MIN值一样了——16384byte;另外,不要忘记这个设置只对当前会话有效。

 

 

 

 

 

 

Thread synchronization —— 线程同步

Posix提供两种线程同步模型:

Mutex:允许多个线程同步地使用共享资源。

Condition variable:一种辅助机制,当共享资源被更改了,通知其他的线程。

那么为什么要提供线程Mutex同步机制呢?

我们编写的C语言代码被as汇编器汇编成汇编指令;对于一个被多个线程访问的共享变量来说,在Thread1中的正在执行的某条汇编指令“刚”把内存中的共享变量的值读到Register寄存器中,就被cpu挂起了;CPU去执行Thread2了,而Thread2也对这个共享变量进行了操作改变了她的值——和Thread1的寄存器存的值不同了,从某种意义来说Thread1的共享变量的值是个赝品了。过了一段时间CPU又切回到了Thread1继续执行,而此时,要操作的那个寄存器中存储的值已经没有意义了——因为她已经和原来的变量一样,对于同一个变量同一时间有两个值,问题自然就来了有可能是微风吹过毫发无损,也有可能是灭顶之灾~~~所以说现在就需要英雄的出现,Mutex参上!原理非常简单,当一个线程要访问共享变量,我把它所在一个房间里;不让其他的线程访问了。等我访问完了,把房间打开,让其线程访问。这样就不会造成共享变量值不一致了。

那么为什么提供了Condition variable这个机制呢?

源于我们在应用程序的中要测试某个变量的值是否改变了的情况,生产者-消费者模型当中,消费者要用轮询poll技术(通俗点就是while循环)来检测标记产品的数量的变量是否大于0可以被消费者消费掉;通常轮询机制是非常浪费CPU资源的。所以Condition variable就应运而生了;原理格外简单:在产品超过0的情况下,主动给消费者发个短信pthread_cond_signal,我们这有货了,省着你老往这跑~~而消费者呢,发现没有货了,也不在傻乎乎的在那一遍遍的Poll测试有没有货,而是通过调用pthread_cond_wait:这个函数做三件事【1】把消费者得到的保护共享变量Mutex解锁,这样生产者才可以访问这个变量,产生产品,【2】之后调用sleep函数是消费者睡一会儿,等来短信了把消费者吵醒,【3】被吵醒,之后获得那个全局的Mutex再次锁上,之后函数返回。

为什么要有静态分配和动态分配Mutex变量两种方式呢?动态初始化和静态初始化有什么区别呢?

【1】我们先来说说什么是动态初始化和静态初始化:

Dynamically Initialization动态初始化,即Run-time运行时初始化,对于存放在Stack和Heap上的变量的初始化就是动态初始化,现在你可以想想平时遇到的那些变量属于这一类。

Statically Initializtion静态初始化,是在Run-time之前Compile、link、Load这三个阶段进行的初始化,其中static变量和全局变量的初始化就是这一类。其中大多数静态初始化都在Compile/link阶段完成了,我知道的Load阶段进行的静态初始化是定义static或全局变量分配一个特别大的空间时(比如:100M),编译器会将初始化延迟到Load阶段。

【2】再来说说变量的分配,共有3种分配方式:

Automatic memory allocation自动分配,在函数内部分配的除static变量之外的所有变量,即自动变量都属于这一类型。

Static memory allocation  静态分配,在Run-time运行之前,由编译器,链接器以及程序加载器分配的变量,全局变量和静态变量就是了,注意一定要理清静态变量和静态分配,不要搞混了。这一类变量被存在所谓的内存的静态区当中,其实,就是程序正文区和堆之间的部分,可以被细分为.rodata(常量)、.data(初始化的变量)、.bss(未初始化的变量)。这类变量的生命周期是和Process进程存在的时间一样的。

Dynamic memory allocation动态分配,在Run-time时分配,大家所熟悉的malloc、new就是了。

【3】好了,回过头来看我们的Mutex;对比下,问题不言自明,各有优缺~

静态分配(函数体之外所有变量、函数体之内的静态变量都是这样分配的)的Mutex变量用PTHREAD_MUTEX_INITIALIZER初始化后,就具有默认的属性,以及被存在静态区上了。

大家需要注意下,在函数体内部的非静态的局部Mutex变量是自动分配在栈上的,理论上也可用PTHREAD_MUTEX_INITIALIZER初始化,但这样做没有意义,因为Mutex是各个线程之间的信息交换点,局部变量显然担当不了此重任——只能被一个线程所知晓并访问。

pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);动态初始化第一个好处就是可以自己定制Mutex的属性了,另外Mutex可以被存放在Heap堆上:试想下,你在创建了一个包含Mutex的链表,你应该怎么初始化它?当然也可以动态初始化存放在栈上的Mutex。可是,反过来你却不能静态初始化存放在堆上的Mutex,why?除非你玩穿越,否则这是不可能,应为静态初始化在program运行之前,而堆是运行时的概念。

int pthread_mutex_destroy(pthread_mutex_t *mutex);在堆上分配的,自然要手动销毁了~~

相关论述参见这里:Is any difference between default and static mutex initialization? 还有这里:man 3 pthread_mutex_init

避免死锁的方法

【1】所有线程对于不同的锁使用相同顺序访问。

【2】try, and then back off策略;当线程要访问第二个Mutex资源时,用pthread_mutex_trylock测试下,如果失败就释放已拥有的第一个锁。

Mutex Types

【1】PTHREAD_MUTEX_NORMAL:不侦测死锁是否发生

一个线程对已经被自己上锁的Mutex,再次上锁将产生Deadlock。解锁一个被别的线程锁住的Muex和未上锁的Mutex的行为都是未定以的。

【2】PTHREAD_MUTEX_ERRORCHECK:提供错误告知

一个线程对已经被自己上锁的Mutex,再次上锁将返回一个错误。解锁一个被别的线程锁住的Muex和未上锁的Mutex的将返回一个错误。

【3】PTHREAD_MUTEX_RECURSIVE:允许递归上锁

一个线程对已经被自己上锁的Mutex,再次上锁成功返回。理所当然的需要等量的解锁操作。解锁一个被别的线程锁住的Muex和未上锁的Mutex的将返回一个错误。

【4】PTHREAD_MUTEX_DEFAULT:Linux上等价于PTHREAD_MUTEX_NORMAL

/usr/include/pthread.h 定义:PTHREAD_MUTEX_DEFAULT = PTHREAD_MUTEX_NORMAL

 

 

 

Thread Safety——线程安全

Non-thread-safe functions线程不安全函数

我们首先看下CS:APP中线程安全函数的定义:一个函数被认为线程安全,当且仅当被多个并发线程同时反复调用,仍会产生正确的结果。

CS:APP富有深刻洞察力的给出了四类不是线程安全的函数,具体论述见CS:APP2e第12章:

【1】Failing to protect shared variables不保护共享变量的函数。

【2】Relying on state across multiple function invocations需要调用其他函数的函数。

【3】Returning a pointer to a static variable返回值是静态(分配)变量;我觉的这一类情况中函数的返回值一般都是static类型的,其实返回值是全局变量也属于这一类,所以我加了“(分配)”做解释。

【4】Calling thread-unsafe functions调用了线程不安全函数的函数。

CS:APP关于Reentrant function的描述:可重入函数属于线程安全函数,特别之处在于:他们不引用Shared Data共享数据。 Explicitly reentrant显式可重入:函数参数为传值传递,函数所使用变量只用自动分配的栈变量。Implicity reentrant隐式可重入:参数含指针,但小心处理就可。

 

Per-thread Storage——线程数据存储

One-Time Initialization一生就一次

TLPI中说明了pthread_once这个函数存在的主要原因是:早期的Pthread库,无法静态初始化Mutex变量;作为替代,使用pthread_mutex_init动态初始化。那么一个线程例程函数(就是线程执行的函数)中调用了一个需要初始化Mutex的库函数;因为多线程嘛,每个线程如果没有什么机制限制的化,都会初始化Mutex一次。Man手册中指出:对一个已初始化的变量初始化的结果是不确定的!况且多次初始化会出现:一个线程初始化后更改了共享变量的值后,另一个线程又初始化了一次将上个线程的工作抹杀掉了。所以pthread_once就是实现这个功能:保证多个线程只对这个Mutex初始化一次,怎么做到的呢?虽然后来支持了静态初始化Mutex变量,但pthread_once仍然留了下来,没有被标准剔除,原因如下;当然,还有其他的应用原因。

 

 

 

Thread-Specific Data每个线程特定的私有数据

使用Thread-Specific Data【TSD】这个技术可以使那些已存在的Non-thread-safe线程不安全的函数在不改变接口(重点!)的前提下变成Thread-seafe线程安全(CS:APP介绍了其他几种线程安全化的技术),使每个线程都能使用自己私有的数据,不干扰到彼此(Threads)。要理解这个技术具体的实现,你需要先来理清几点重要的概念:【1】Thread-routine线程例程函数:新创建的Peer-thread线程在整个生命周期内执行的函数,类似于大家熟悉的Main-thread的main函数。【2】多个线程可以同时执行相同的Thread-routine线程例程函数,也可以同时执行不同的线程例程函数。【3】(尤为重要)Pthread-key与Thread-routine线程例程函数是一一对应的关系,也就是说Pthread-key键是用来区别不同的线程例程函数的~~【4】一个线程可以指定多个私有的数据存储区。【5】这个特定于线程例程函数的Key只能被pthread_key_create创建一次,所以pthread_once,参上!【6】NPTL线程库在为我们在每一个进程内都维护两个数据结构:first,全局Pthread-key数组,用来记录进程内所有键的使用情况;second,对于进程内每一个被使用的Pthread-key键,都有一个数组用来存储所有使用这个键(实际上使用的是那个键对应的线程例程函数:-))的线程的所有(一个线程可以有多个存储区:同一个线程使用相同的Key和不同的存储区多次调用pthread_setspecific)私有数据存储区的地址。【7】pthread_getspecific函数的查找过程:参数Key--->哪一个Thread--->哪一个数据存储区--->Address;好的,我们现在回到开始,要使一个一个线程不安全的函数编程一个线程安全的函数,常用的一个技术就是重写这个函数,又因为这一类线程不安全的函数常常是因为使用了静态的变量作为函数返回值,造成多个线程同时调用这个函数会彼此覆盖返回值;而重写这个函数一般是改变函数接口,增加由调用指定的数据返回值的存储位置,来避免覆盖发生;你也许回看到一些以_r作为后缀的函数,他们多半是使用这种改变接口的技术达到线程安全的目的。所以在同样重写的情况,我们应用TSD技术就可以不改变API的接口;我想这是有划时代意义的,至少对于那些老的程序来说,要知道他们什么都们改变就达到Tread-safe的目的了:-)

这是每个进程内Pthread-key键的限制个数:

 

 

Thread-Local Storage线程局部的私有数据


 

 

 

 

 

Thread Cancellation——取消线程

 

Thread and Signal——线程与线号

Origin起源——一切的答案都在这里

故事的发生,追溯到40年前——最初Signal信号机制是为Process进程模型量身打造的;可是,在20年前的某个夜黑风高的晚上(在Silently Hill这个地方)Pthread出现了,伴随而来的是代沟——为了既保持信号原有针对进程的语义(定义)同时又要保证信号与多线程模型和谐共处。而要想透彻的了解现在的信号与多线程,你需要追根溯源(回到寂静岭),理解清下面几个概念:

保持原有Signal信号机制是针对进程语义:

信号的deliver递送(产生)、Signal handler信号处理器的dispositions部署(处理)、信号的产生的结果是针对进程的(结果);

【1】也就是说Signal handler信号处理器是配置给一个进程的,进程内的多个线程是公用这一个Signal handler的;

【2】也就是说,信号是deliver递送给进程的,进程内的多个线程是共享并处里所有这些信号的;

【3】也就是说,当一个线程处理一个signal的结果是Stop或Terminate,那么整个进程就Stop或Terminate。

为了与线程模型相适应,信号针对线程的属性如下:

【0】一个进程内的所有产生的信号分为不相交的两个集合:针对进程的——所有线程共享;针对单个线程的——每个线程自己私有,如下:

 

当在某个线程的Context上下文的硬件指令产生的信号SIGBUS, SIGFPE, SIGILL, and SIGSEGV;

当线程去写一个Broken的Pipe管道,产生的SIGPIPE;

当pthread_kill() or pthread_sigqueue()发送给某个特定线程;

【1】每个线程有自己独立的Signal  Mask信号掩码集,也就是说Main-thread和每个Peer-thread都可以自主的决定阻塞哪个信号。这样就是我们在编程时,独立创建一个线程处理所有的信号成为可能——其他线程阻塞所有信号。

【2】一个信号被递送给包含多个线程的进程时,kernel随机的从没有阻塞该信号的线程中选择一个,来处理这个信号。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

原创粉丝点击