• 线程1. 线程概述1.1 从进程到线程1.2 用户级线程和内核级线程1.3 线程的创建1.3.1 pthread_create函数定义代码示例注意事项A: 主线程的消亡,引发的一些问题B: 链接 -lpthread C: 了解1.3.2 线程函数错误处理1.4 线程和数据共享1.4.1 共享全局变量 (数据段)1.4.2 共享堆空间1.4.3 访问栈数据1..4.4 其它注意: long作为8字节参数注意: 栈数据共享的释放1.5 获取线程的退出状态1.5.0 补充: ps -elLf1.5.1 pthread_join函数定义代码示例注意事项A. 注意: 阻塞线程B. 注意: 捕获和被捕获线程的关系C. 注意: 局部变量问题1.6 线程的主动退出1.6.1 线程的return退出1.6.2 通过pthread_exit退出函数定义代码示例其它注意A. 注意B. 了解C. 一个stdout的特殊情况D. 注意1.7 线程的被动退出1.7.1 线程的取消函数原理函数定义代码示例注意事项: 了解, 不重要1.7.2 手打取消点函数定义代码示例1.7.3 总结补充1.8 资源清理1.8.1 pthread_cleanup函数原理函数定义代码示例注意事项2. 线程的同步和互斥2.1 互斥锁2.1.1 锁的基本情况2.1.2 锁的使用函数原理和定义代码示例注意事项A gettimeofday: 了解B pthread_mutex_trylockC 其它锁1:了解D 其它锁2: 了解2.1.3 锁的类型锁的类型函数定义代码示例代码练习2.2 条件变量2.2.1 pthread_cond_wait函数原理函数定义代码示例注意事项2.2.2 pthread_cond_timedwait函数特点函数定义代码示例2.2.3 pthread_cond_broadcast函数说明代码示例2.2.4 代码练习2.3 其它: 了解2.3.1 线程的属性2.3.2 线程安全2.3.3 可重入性

    线程

    1. 线程概述

    1.1 从进程到线程

    在经过之前课程的学习之后,我们已经了解进程的基本概念:进程是正在执行的程序,并且是系统资源分配的基本单位

    当用户需要在一台计算机上去完成多个独立的工作任务时,可以使用多进程的方式,为每个独立的工作任务分配一个进程。多进程的管理则由操作系统负责——操作系统调度进程,合理地在多个进程之间分配资源,包括CPU资源、内存、文件等等。除此以外,即便是一个单独的应用,采用多进程的设计也可以提升其吞吐量,改善其响应时间。假如在处理任务的过程中,其中一个进程因为死循环或者等待IO之类的原因无法完成任务时,操作系统可以调度另一个进程来完成任务或者响应用户的请求。比如在文本处理程序当中,可以并发地处理用户输入和保存已完成文件的任务。随着进程进入大规模使用,程序员在分析性能的时候发现计算机花费了大量的时间在切换不同的进程上面。当一个进程在执行过程中,CPU的寄存器当中需要保存一些必要的信息,比如堆栈、代码段等,这些状态称作上下文上下文切换这里涉及到大量的寄存器和内存之间的保存和载入工作。除此以外,Linux操作系统还支持虚拟内存,所以每个用户进程都拥有自己独立的地址空间,这在实现上就要求每个进程有自己独立的页目录表(页目录表: 映射虚拟地址到物理地址)(TLB是是一个页目录表的缓存,来存储最近使用的物理和虚拟地址映射)。所以,具体到Linux操作系统,进程切换包括两步:首先是切换页目录表,以支持一个新的地址空间;然后是进入内核态,将硬件上下文,即CPU寄存器的内容以及内核态栈切换到新的进程。

    为了缩小切换进程的开销,线程的概念便诞生了。线程又被称为轻量级进程 (Light Weight Process,LWP)我们将一个进程分解成多个线程,每个线程是独立的运行中程序实体,线程之间并发运行,这样线程就取代进程成为CPU时分配和调度的最小单位,在Linux操作系统当中,每个线程都拥有自己独立的 task_struct 结构体,当然,在属于同一个进程多个线程中, task_struct 中大量的字段是相同的或者是共享的。注意到在Linux操作系统中,线程并没有脱离进程而存在。而计算机的内存、文件、IO设备等资源依然还是按进程为单位进行分配,属于同一个进程的多个线程会共享进程地址空间,每个线程在执行过程会在地址空间中有自己独立的栈,而堆、数据段、代码段、文件描述符和信号屏蔽字等资源则是共享的。

    因为属于同一个进程的多个线程是共享地址空间的,所以线程切换的时候不需要切换页目录表,而且上下文的内容中需要切换的部分也很少,只需要切换栈和PC指针以及其他少量控制信息即可,数据段、代码段等信息可以保持不变。因此,切换线程的花费要小于切换进程的。

    同一个进程的多个线程各自拥有自己的栈,这些栈虽然是独立的,但是都位于同一个地址空间中,所以一个线程可以通过地址去访问另一个线程的栈区。

    了解:

    在Linux文件系统中,路径 /proc 挂载了一个伪文件系统,通过这个伪文件系统用户可以采用访问普通文件的方式(使用read系统调用等),来访问操作系统内核的数据结构。其中,在 /proc/[num] 目录中,就包含了pid为num的进程的大量的内核数据。而在 /proc/[num]/task 就包含本进程中所有线程的内核数据。我们之前的编写进程都可以看成是单线程进程。

    1.2 用户级线程和内核级线程

    在Linux2.4以及以前版本,因为还没有线程的概念,Linux内核不知道什么是线程。而后来随着技术发展,因为创建进程开销的资源更多,且进程间的切换相比线程更慢,线程带来的好处被大家所认识。于是我们希望Linux能实现多线程编程,然而要修改一个操作系统并不是件容易的事情,于是采用的办法是写函数来实现,而不是去修改操作系统的内核。而这些函数也就是最初的线程库,由于在Linux内核中没有线程的概念,因此这种线程是用进程来模拟的。因此这种线程也被称为用户级线程。

    虽然当时的线程库已经和POSIX的标准非常接近了,但是在Linux的线程实现版本和POSIX标准之间还是存在着细微的差别,最明显的是关于信号处理部分,这些差别中的大部分都受底层Linux内核的限制,而不是函数所能改变的。许多项目都在研究如何才能改善Linux对线程的支持,这其中大部分工作集中在了如何将用户级的线程映射到内核级的线程。

    其中IBM公司的NGPT(Next Generation POSIX Threads),和Redhat公司的NPTL(Native POSIX Thread Library)通过修改Linux内核来支持新的线程库,两者都极大地提升了性能。在2002年,NGPT项目组宣布,由于不希望分化团队,所以停止为NGPT添新功能。也因此NPTL成为了Linux线程的新标准。在NPTL中, 内核会处理每个线程堆栈所使用的内存的回收工作。它甚至还通过在清除父线程之前进行等待,从而实现对所有线程结束的管理。

    目前使用最广泛的线程库名为NPTL (Native POSIX Threads Library),在Linux内核2.6版本之后,它取代了传统的LinuxThreads线程库。NPTL支持了POSIX的线程标准库,在早期,NPTL只支持用户级线程,随着内核版本的迭代,现在每一个用户级线程都对应一个内核态线程,这样NPTL线程本质就成为了内核级线程,可被操作系统调度

    用户级线程和内核级线程在实现方式、运行状态、切换方式、调度方式、对系统资源的访问、创建和销毁等方面存在明显的差异

    用户级线程和内核级线程的主要区别体现在以下方面:

    1. 实现方式:用户级线程是在用户程序中实现的,而内核级线程是由操作系统内核创建和管理的。

    2. 运行状态:用户级线程运行在用户态下,而内核级线程可以访问操作系统的所有资源。

    3. 切换方式:用户级线程的切换由应用程序自己控制,而内核级线程的切换需要操作系统的支持。

    4. 调度方式:用户级线程的调度由应用程序自己控制,而内核级线程的调度由操作系统内核负责。

    5. 等...

    1.3 线程的创建

    使用线程的思路其实和使用进程的思路类似,用户需要去关心线程的创建、退出和资源回收等行为,初学者可以参考之前学习进程的方式对比学习线程对应的库函数。下面是常用的线程库函数和之前的进程中的对应关系。

    1.3.1 pthread_create

    函数定义

    线程创建使用的函数是 pthread_create ,这个函数的函数原型如下( man pthread_create )

    代码示例
    注意事项
    A: 主线程的消亡,引发的一些问题

    在上面的例子当中,如果主线程不sleep, 或者将 sleep(1) 改成 usleep(20)或者别的 (设个时间也取决于系统的cpu核心和执行效率),线程并发执行的特点会导致一些看上去非常奇怪的结果:在某次执行的时候,标准输出上有一定的几率显示两条相同的语句。

    产生这种问题的原因是这样的,stdout缓冲区在多个线程之间是共享,当执行 printf 时,会首先将stdout的内容拷贝到内核态的文件对象中,再清空缓冲区,当主线程终止导致所有线程终止时,可能子线程已经将数据拷贝到了内核态(此时第一份语句已经打印了),但是stdout的内容并未清空,此时进程终止,会把所有缓冲区清空,清空缓冲区的行为会将留存在缓冲区的内容直接清空并输出到标准输出中,此时就会出现内容的重复打印了。

    B: 链接 -lpthread

    在使用线程相关的函数之后,在链接时需要加上 -lpthread 选项以显式链接线程库

    C: 了解

    在一个进程当中的不同线程都拥有自己独立唯一的线程id(thread id),NPTL使用pthread_t类型来保存线程id,在不同的操作系统中,pthread_t底层实现不同,具体到Linux是一个无符号整型数 (也可以使用函数 pthread_self 获取本线程的id)。

    1.3.2 线程函数错误处理

    之前的POSIX系统调用和库函数在调用出错的时候,通常会把全局变量 errno (按进程隔离)设置为一个特别的数值以指示报错的类型,这样就可以调用 perror 以显示符合人阅读需求的报错信息。但是在多线程编程之中,全局变量是各个线程的共享资源,很容易被并发地读写,所以pthread系列的函数优化了检错方式,它会根据pthread系列的函数不同的返回值, 让strerror 函数根据这个错误返回值,使用 strerror 函数可以根据返回值显示报错字符串。(man strerror)

    1.4 线程和数据共享

    1.4.1 共享全局变量 (数据段)

    多个线程是共享一片地址空间的,所以各个线程可以并发地访问的同一个数据段、堆空间和其他位置。

    下面是一个共享数据段的例子:

    1.4.2 共享堆空间

    堆空间自然也是可以共享的,我们通过会将堆空间的首地址作为参数在创建线程的时候进行传递,无论是子线程还是主线程访问的区域都是同一片空间。

    eg:

    1.4.3 访问栈数据

    需要注意的是,虽然各个线程执行的过程中拥有自己独立的栈区,但是这些所有的栈区都是在同一个地址空间当中,所以一个线程完全可以访问到另一个线程栈帧内部的数据。(如果将主线程栈帧数据的地址作为参数传递给各个子线程,就一定要注意并发访问的情况,有可能另一个线程的执行会修改掉原本想要传递数据的内容)

    1..4.4 其它

    注意: long作为8字节参数

    虽然说 arg 是一个 void* 类型的参数,这暗示着用户可以使用该参数来传递一个数据的地址,但是有些情况下我们只需要传递一个整型数据,在这种情况,除了传递参数地址以外, 用户可以直接把 void* 类型的参数当成是一个8字节的普通数据(比如long)进行传递。

    传递地址

    传递8字节数据

    注意: 栈数据共享的释放

    共享栈数据,一定要注意内存的释放顺序

    EgCode:

    1.5 获取线程的退出状态

    我们可以通过pthread_join()方法捕获线程的退出和退出状态。

    1.5.0 补充: ps -elLf

    补充

    运行如下代码,在代码运行中,并通过ps -elLf命令查看所有线程状态,观察线程变化

    EgCode:

    1.5.1 pthread_join

    函数定义

    使用 pthread_join() 捕获线程的退出和退出状态。

    代码示例
    注意事项
    A. 注意: 阻塞线程

    pthread_join 可以使本线程处于等待/阻塞状态,直到指定的 thread 终止, 就结束等待,并且捕获到的线程终止状态(返回参数)存入 retval 指针所指向的内存空间中。

    EgCode:

    B. 注意: 捕获和被捕获线程的关系

    ps2: pthread_join可以捕获一个线程,一个线程的退出也可以由另一个线程使用pthread_join捕获,需要注意的是,另一个线程的选择是任意的,不需要是被捕获线程创建者。 ps3: 一个线程的退出,可以被多个线程通过pthread_join试图捕获(多个线程调用该函数获取同一个线程的执行结果),但是只有一个pthread_join能捕获成功。别的都将失败。

    C. 注意: 局部变量问题

    指针retval捕获线程函数内部的局部数据, 可能会存在问题(比如局部变量)。

    1.6 线程的主动退出

    1.6.1 线程的return退出

    1, 当存在一个线程, 这个线程正常运行结束, 其入口调用函数通过return运行结束。 此线程将正常退出。

    2, 我们可以通过pthread_join捕获到线程return退出, 以及退出时返回数据.

    EgCode: (可配合 ps -elLf命令, 查看子线程的结束时机。)

    通过pthread_join捕获正常return线程

    1.6.2 通过pthread_exit退出

    函数定义

    使用 pthread_exit 函数可以主动退出线程。

    无论这个函数是否是在 start_routine(主调函数/线程入口函数) 中被调用(其行为类似于进程退出的 exit), 都将退出该线程 。

    代码示例

    EgCode: pthread_exit 函数可以主动退出线程

    EgCode: 无论这个函数是否是在start_routine函数中被调用, 都将退出该线程 。

    其它注意
    A. 注意

    ps: 指针retval建议不要指向该退出线程函数内部的局部数据。

    ps: 在正常return结束的线程中, 其start_routine的最终返回值, 等价于pthread_exit退出线程的携带retval,等待捕获程结束的pthread_join()函数都可以接收到线程的返回值, 对于pthread_join来说,并不认为两者有什么异同。

    ps: pthread_join()函数的第二个参数线程的返回值的位置, 可以用NULL填充, 表示不捕获线程返回值。

    B. 了解

    ps: 如果子线程通过pthread_exit(NULL) 退出, 那么pthread_join()捕获的线程退出状态和 return 0以及pthread_exit(0) 结束线程一样,捕获返回结果为0。

    C. 一个stdout的特殊情况

    每个线程开辟了一个独属于自己的栈, 而堆/数据段/代码段/..是共享的。 同时也共享一个进程的stdout(标准输出)。

    D. 注意

    pthread_exit()可以自动调用线程清理程序(参考pthread_cleanup_push()),return 则不具备这个能力。

    1.7 线程的被动退出

    我们在上面分别介绍了: 通过代码正常运行return结束, 以及通过执行pthread_exit函数退出线程; 而这两种方式都是由执行线程触发的主动退出方式。

    除了上述线程自己主动退出的情况, 那线程有没有可能被另一个线程通过某些方式强制终止(即被动退出)的情况那?

    1.7.1 线程的取消

    而多线程程序中,一个线程可以借助 pthread_cancel() 函数向另一个线程发送终止执行的信息, 从而实现在一个线程中去终止另一个线程的操作, 从而使被通知的线程被动退出。

    函数原理

    线程的被动取消流程, 如图所示:

    需要注意的是, 当线程A向线程B发送取消线程执行信息的时候, 线程B并不是立即终止执行, 线程B是要先运行到一个叫取消点的位置才终止线程。

     

    取消点

    函数定义

    通过pthread_cancel() 取消一个执行中的线程

    代码示例

    EgCode: 没有取消点无法取消示例

    EgCode: 正常取消示例

    注意事项: 了解, 不重要

    当线程被动取消, 通过pthread_join捕获的结束状态: PTHREAD_CANCELED (参考 man pthread_join 的 description)

    (了解:PTHREAD_CANCELED 是一个由pthread.h定义的, 本质值为-1 )

    (了解:当线程主动退出:pthread_exit(PTHREAD_CANCELED)退出,pthread_join捕获的结束状态也是PTHREAD_CANCELED )

    1.7.2 手打取消点

    从上述的的被动取消过程中, 我们知道pthread_cancel函数是依赖于被取消线程的取消点这一关键标志位; 但是有一种可能,要被取消线程代码中(试图被动取消的代码片段位置)没有取消点, 那么我们该怎么办那?

    我们可以通过pthread_testcancel方法, 手动添加一个取消点。

    函数定义

    通过pthread_testcancel方法, 手动添加一个取消点。

    代码示例

    EgCode:

    1.7.3 总结补充

    了解线程取消的先后顺序流程是很重要的,尤其是在复杂的多线程应用中。当调用 pthread_cancel 来取消一个线程时,以下是线程取消的大致步骤和流程:

    1.8 资源清理

    在引入线程取消之后,程序员在管理资源回收的难度上会急剧提升。

    EgCode:

    在上述代码中, 我们遇到了什么问题?

    为了解决这个问题, 线程库引入了 pthread_cleanup_pushpthread_cleanup_pop函数来管理线程主动或者被动终止时所申请资源(比如文件、堆空间、锁等等)。

    1.8.1 pthread_cleanup

    函数原理

    当线程开始执行时,会创建一个清理栈(thread's stack of thread-cancellation),用于存储注册的清理函数和参数。

    而我们可以通过 pthread_cleanup_pushpthread_cleanup_pop通过向这个清理栈中添加清理函数,以及pop出清理函数执行。从而确保线程的正确终止和资源回收。

    函数定义
    代码示例

    EgCode:

    注意事项

    ps:pthread_cleanup_push()和pthread_cleanup_pop()必须在同一个作用域中要成对出现 。 (此处可参考: /usr/include/pthread.h 对这两个方法的定义 -> 可以发现push和pop的宏定义不是语义完全的,它们必须在同一作用域中成对出现才能使花括号成功匹配。)

    ps:线程运行到pthread_cleanup_pop()方法: 当execute参数0代表弹出栈顶函数并且'不执行'这个函数, 当execute参数非0代表代表弹出栈顶函数并且'执行'这个函数。

    ps: 通过pthread_cancel取消线程, 线程取消,所有入栈的清理函数将按照顺序依次弹栈并执行。

    ps: 调用pthread_exit主动退出线程, 所有入栈的清理函数将按照顺序依次弹栈并执行。

    ps: 当线程因为在start_routine中因为return结束线程, 清理函数栈将不会弹栈。(这取决于操作系统)

     

    2. 线程的同步和互斥

    由于多线程之间不存在隔离,共享同一个地址在提高运行效率的同时也给用户带来了巨大的困扰。在并发执行的情况下,大量的共享资源成为竞争条件(竞争条件是指多个线程并发访问和修改共享资源时可能出现的问题),导致程序执行的结果往往和预期的内容大相径庭。

    EgCode: 两个线程操作一个共享变量, "每个线程每次加一, 加一百万次"

    我们发现结果并不是我们期望的二百万。原因是什么那?

    如果一个程序的结果是不正确的,那么再高的效率也毫无意义。在基于之前进程对并发的研究之上,线程库也提供了专门用于正确地访问共享资源的机制--> 锁。

    2.1 互斥锁

    在多线程编程中,用来控制共享资源的最简单有效也是最广泛使用的机制就是 mutex(MUTual EXclusion),即互斥锁。锁的本质是一个全局的标志位,线程可以对锁进行原子性修改,即所谓的加锁/解锁。当一个线程持有锁的时候,其余线程再尝试加锁时(包括自己再次加锁),都会使自己陷入阻塞状态,直到这把锁 -> 被持有该锁的线程解锁, 其它线程才能拿到这个锁, 拿到锁之后并且解除之前的阻塞状态恢复运行。所以锁在某个时刻永远不能被两个线程同时持有。

    2.1.1 锁的基本情况

    锁的状态

    锁的行为

    锁的要求

    临界区/临界资源

    饥饿

    死锁: 重要

    2.1.2 锁的使用

    函数原理和定义

    定义锁

    初始化锁

    加锁

    解锁

    销毁锁

    代码示例

    EgCode: 锁的基本使用

    EgCode: 锁的基本使用

    注意事项
    A gettimeofday: 了解

    gettimeofday是一个在C语言中常用的函数,是POSIX标准的一部分,用于获取当前的时间和日期。

    EgCode: 消耗时间

    B pthread_mutex_trylock

    pthread_mutex_trylock在获取互斥锁和加锁的时候,它是一种非阻塞锁, 即: 如果锁已经被其他线程持有,则该函数会立即返回一个错误代码,而不是阻塞当前线程; 如果这个锁没有被别人持有, 拿到这个锁

    EgCode: trylock解决死锁问题

    C 其它锁1:了解

    我们目前所使用的pthread_mutex_* 锁是一种睡眠锁,即不满足条件的线程会陷入睡眠状态(进入阻塞态,不占用CPU)。还存在另一个锁称作自旋锁,当线程不满足条件时,线程会一直循环(一直占用CPU), 直到条件成立。

    而我们上面代码: pthread_mutex_trylock配合while循环使用, 也是一种自旋锁的思想.

    在pthread下还提供了一种标准的自旋锁: pthread_spin_lock

    EgCode

    D 其它锁2: 了解

    读锁: 它允许多个线程同时以读模式访问共享资源。当一个线程持有读锁时,其他线程可以同时获取读锁来读取数据,但只能有一个线程可以获取写锁来写入数据。

    写锁: 它只允许一个线程以写模式访问共享资源。当一个线程持有写锁时,其他线程无法获取读锁或写锁,必须等待该锁被释放后才能访问共享资源。

    2.1.3 锁的类型

    锁的类型

    我们在初始化锁的时候, 调用pthread_mutex_init(锁, 类型), 可以给锁设置不同的类型 (可 man pthread_mutexattr_gettype 查看)

    函数定义

    定义锁类型

    初始化类型

    设置类型

    使用

    销毁

    代码示例

    EgCode: 正常锁

    EgCode: 检错锁

    EgCode: 递归锁/可重复锁

    代码练习

    严格上讲上述逻辑中, 追加票的线程, 是一种以轮询方式反复询问票数是否小于5张的方式进行; 这种轮询的方式比较消耗资源(因为其在一直抢占cpu).

    我们接下来可以试图以一种更高效和减少资源消耗的方式, 来实现上述逻辑, 即利用线程的等待/唤醒机制, 来模拟的异步通知方式. --> 条件变量

    2.2 条件变量

    理论上来说,利用互斥锁可以解决所有的同步问题,但是生产实践之中往往会出现这样的问题:一个线程能够执行取决于一个共享资源的具体数值,而该共享资源的数值会随着程序的运行不断地变化,线程也经常在可运行和不可运行之间动态切换,假如纯粹使用互斥锁来解决问题的话,就会出现大量的重复的“加锁-检查条件不满足-解锁”的行为,这样的话,不满足条件的线程会经常试图占用CPU资源,上下文切换也会非常频繁。

    对于这样依赖于共享资源作为条件来控制线程之间的同步的问题,我们希望采用一种无竞争的方式让多个线程在共享资源处会和——这就是条件变量

    2.2.1 pthread_cond_wait

    函数原理

    我们可以在线程运行过程中, 通过调用pthread_cond_wait让不满足条件的线程主动阻塞, 等待被唤醒.

    当通过pthread_cond_wait陷入阻塞的时候, 会先释放锁.

    pthread_cond_wait被从阻塞状态唤醒的时候, 会先加锁, 然后继续执行其后的代码逻辑.

    函数定义

    定义条件变量

    初始化条件变量

    陷入阻塞并释放锁

    唤醒以指定条件变量阻塞的线程, 并使其重新获取锁

    销毁条件变量

    代码示例

    重写上面的卖票程序

    注意事项

    A, pthread_cond_signal一次只能唤醒一个被pthread_cond_wait放进唤醒队列中线程

    B, 通过pthread_cond_signal唤醒的线程, 如果无法获取锁, 会立即进入阻塞, 直到锁空闲, 再自动唤醒(无需pthread_cond_signal)并持有锁( 第一个意思: 即使被pthread_cond_signal唤醒也不一定能拿到锁; 第二意思: 拿不到等一会重新拿 )

    2.2.2 pthread_cond_timedwait

    函数特点

    pthread_cond_timedwait是一个可设置超时的pthread_cond_wait

    函数定义

    条件变量的声明/初始化/唤醒/销毁, 皆同pthread_cond_wait

    代码示例

    2.2.3 pthread_cond_broadcast

    函数说明

    pthread_cond_broadcast 用于唤醒以指定条件变量阻塞的线程, 并使其重新获取锁, 但是和pthread_cond_signal不同的是, 虽然都是唤醒阻塞线程, pthread_cond_signal 每次从阻塞队列中取出一个唤醒, 而pthread_cond_broadcast 是以广播的方式把指定条件变量的阻塞队列线程全部唤醒.

    ps: 要注意虚假唤醒问题(eg: 以一个生产者唤醒多个消费者为例)

    代码示例

    2.2.4 代码练习

    生产者-消费者:

    2.3 其它: 了解

    2.3.1 线程的属性

    了解

    在线程创建的时候,用户可以给线程指定一些属性,用来控制线程的调度情况、CPU绑定情况、屏障、线程调用栈和线程分离等属性。这些属性可以通过一个 pthread_attr_t 类型的变量来控制,可以使用pthread_attr_set 系列设置属性,然后可以传入 pthread_create 函数,从控制新建线程的属性。(man -k pthread_attr_set)

    在这里,我们以 pthread_attr_setdetachstate 为例子演示如何设置线程的属性。分离/脱离属性影响一个线程的终止状态是否能被其他线程使用 pthread_join 函数捕获终止状态。如果一个线程设置了分离/脱离属性,那么另一个线程使用 pthread_join 时会返回一个报错

    2.3.2 线程安全

    由于多线程之间是共享同一个进程地址空间,所以多线程在访问共享数据的时候会出现竞争问题,这个问题不只会发生在用户自定义函数中,在一些库函数执行中也可能会出现竞争问题。

    有些库函数在设计的时候会申请额外的内存,或者会在静态区域分配数据结构——一个典型的库函数就是 ctime 。 ctime函数会把日历时间字符串存储在静态区域。

    在上述例子中的,子线程中的 str 是指向的区域是静态的,所以即使子线程没有作任何修改,但是因为主线程会调用ctime 修改静态区域的字符串,子线程两次输出的结构会有不同。

    使用 ctime_r 可以避免这个问题,ctime_r 函数会增加一个额外指针参数,这个指针可以指向一个线程私有的数据,比如函数栈帧内,从而避免发生竞争问题

    类似于 ctime_r 这种函数是线程安全的,如果额外数据是分配在线程私有区域的情况下,在多线程的情况下并发地使用这些库函数是不会出现并发问题的。

    在帮助手册中,库函数作者会说明线程的安全属性。

    2.3.3 可重入性

    在信号/多线程情况下,一个函数异步地被重新调用过程中,如果重复的函数调用有可能会导致错乱的结果,那么这些函数就是不可重入的。

    理论上来说,实现可重入函数有下列需求:

    • 访问静态或者全局数据必须采取同步手段。

    • 不能调用非可重入函数。

     

    一个比较典型的不可重入函数例子就是 malloc 函数, malloc 函数必然是要修改静态数据的,为了保证线程安全性, malloc 函数的实现当中会存在加锁和解锁的过程,假如 malloc 执行到加锁之后,解锁之前的时候,此时有信号产生并且递送的话,线程会转向执行信号处理回调函数,假如信号处理函数当中又调用了 malloc 函数,此时就会导致死锁—>这就是 malloc 的不可重入性。