zoukankan      html  css  js  c++  java
  • TLPI读书笔记第29章:线程介绍

    29.1 概述

    与进程( process)类似1,线程( thread)是允许应用程序并发执行多个任务的一种机制。如图 29-1 所示,一个进程可以包含多个线程。同一程序中的所有线程均会独立执行相同程序,且共享同一份全局内存区域,其中包括初始化数据段( initialized data)、未初始化数据段( uninitialized data),以及堆内存段( heap segment)。 (传统意义上的 UNIX 进程只是多线程程序的一个特例,该进程只包含一个线程。 )

    同一进程中的多个线程可以并发执行。在多处理器环境下,多个线程可以同时并行。如果一线程因等待 I/O 操作而遭阻塞,那么其他线程依然可以继续运行。

    对于某些应用而言,线程要优于进程。传统 UNIX 通过创建多个进程来实现并行任务。以网络服务器的设计为例,服务器进程(父进程)在接受客户端的连接后,会调用 fork()来创建一个单独的子进程,以处理与客户端的通信(可参考 60.3 节)。采用这种设计,服务器就能同时为多个客户端提供服务。虽然这种方法在很多情境下都屡试不爽,但对于某些应用来说也确实存在如下一些限制。

    1.进程间的信息难以共享。由于除去只读代码段外,父子进程并未共享内存,因此必须采用一些进程间通信方式,在进程间进行信息交换。 2.调用 fork()来创建进程的代价相对较高。即便利用 24.2.2 节所描述的写时复制( copy-on-write)技术,仍然需要复制诸如内存页表( page table)和文件描述符表( file descriptor table)之类的多种进程属性,这意味着 fork()调用在时间上的开销依然不菲。 线程解决了上述两个问题。 1.线程之间能够方便、快速地共享信息。只需将数据复制到共享(全局或堆)变量中即可。不过,要避免出现多个线程试图同时修改同一份信息的情况,这需要使用第 30章描述的同步技术。 2.创建线程比创建进程通常要快 10 倍甚至更多。(在 Linux 中,是通过系统调用 clone()来实现线程的)线程的创建之所以较快,是因为调用 fork()创建子进程时所需复制的诸多属性,在线程间本来就是共享的。特别是,既无需采用写时复制来复制内存页,也无需复制页表

    除了全局内存之外,线程还共享了一干其他属性(这些属性对于进程而言是全局性的,而并非针对某个特定线程),包括以下内容。

    1. 进程 ID( process ID)和父进程 ID。

    2. 进程组 ID 与会话 ID( session ID)。

    3. 控制终端。

    4. 进程凭证( process credential)(用户 ID 和组 ID )。

    5. 打开的文件描述符。

    6. 由 fcntl()创建的记录锁( record lock)。

    7. 信号( signal)处置。

    8. 文件系统的相关信息:文件权限掩码( umask)、当前工作目录和根目录。

    9. 间隔定时器( setitimer())和 POSIX 定时器( timer_create())。

    10. 系统 V( system V)信号量撤销( undo, semadj)值( 47.8 节)。

    11. 资源限制( resource limit)。

    12. CPU 时间消耗(由 times()返回)。

    13. 资源消耗(由 getrusage()返回)。

    14. nice 值(由 setpriority()和 nice()设置)。

    各线程所独有的属性,如下列出了其中一部分。

    1. 线程 ID( thread ID, 29.5 节)。

    2. 信号掩码( signal mask)。

    3. 线程特有数据( 31.3 节)。

    4. 备选信号栈( sigaltstack())。

    5. errno 变量。

    6. 浮点型( floating-point)环境(见 fenv(3))。

    7. 实时调度策略( real-time scheduling policy)和优先级( 35.2 节和 35.3 节)。

    8. CPU 亲和力( affinity, Linux 所特有, 35.4 节将加以描述)。

    9. 能力( capability, Linux 所特有,第 39 章将加以描述)。

    10. 栈,本地变量和函数的调用链接( linkage)信息。

    29.2 Pthreads API 的详细背景

    20 世纪 80 年代末、 90 年代初,存在着数种不同的线程接口。 1995 年 POSIX.1c 对 POSIX 线程 API 进行了标准化,该标准后来为 SUSv3 所接纳。 有几个概念贯穿整个 Pthreads API,在深入探讨 API 之前,将简单予以介绍。

    线程数据类型(Pthreads data type)

    Pthreads API 定义了一干数据类型,表 29-1 列出了其中的一部分。后续章节会对这些数据类型中的绝大部分加以描述

    SUSv3 并未规定如何实现这些数据类型,可移植的程序应将其视为“不透明”数据。亦即,程序应避免对此类数据类型变量的结构或内容产生任何依赖。尤其是,不能使用 C 语言的比较操作符( ==)去比较这些类型的变量。

    线程和 errno

    在传统 UNIX API 中, errno 是一全局整型变量。然而,这无法满足多线程程序的需要。如果线程调用的函数通过全局 errno 返回错误时,会与其他发起函数调用并检查 errno 的线程混淆在一起。换言之,这将引发竞争条件( race condition)。

    因此,在多线程程序中,每个线程都有属于自己的 errno。 在 Linux 中, 线程特有 errno 的实现方式与大多数 UNIX 实现相类似: 将 errno 定义为一个宏,可展开为函数调用,该函数返回一个可修改的左值( lvalue),且为每个线程所独有。(因为左值可以修改,多线程程序依然能以 errno=value 的方式对 errno 赋值。 )一言以蔽之, errno 机制在保留传统 UNIX API 报错方式的同时,也适应了多线程环境

    Pthreads 函数返回值

    从系统调用和库函数中返回状态,传统的做法是:返回 0 表示成功,返回-1 表示失败,并设置 errno 以标识错误原因。 Pthreads API 则反其道而行之。所有 Pthreads 函数均以返回 0 表示成功,返回一正值表示失败。这一失败时的返回值,与传统 UNIX 系统调用置于 errno 中的值含义相同。 由于多线程程序对 errno 的每次引用都会带来函数调用的开销,因此,本书示例并不会直接将 Pthreads 函数的返回值赋给 errno,而是使用一个中间变量,并利用自己实现的诊断函数errExitEN()( 3.5.2 节),如下所示

    编译 Pthreads 程序

    在 Linux 平台上, 在编译调用了 Pthreads API 的程序时, 需要设置 cc -pthread 的编译选项。 使用该选项的效果如下。 1.定义_REENTRANT 预处理宏。这会公开对少数可重入( reentrant)函数的声明。 2.程序会与库 libpthread 进行链接(等价于-lpthread)。

    29.3 创建线程

    启动程序时,产生的进程只有单条线程,称之为初始( initial)或主( main)线程。本节将讨论其他线程的创建过程。

    #include<pthread.h>
    int pthread_create(pthread_t *thread,const pthread_attr_t *attr,void *(*start)(void *),void *arg);

    函数 pthread_create()负责创建一条新线程。 新线程通过调用带有参数 arg 的函数 start (即 start(arg)) 而开始执行。 调用 pthread_create()的线程会继续执行该调用之后的语句。(如 28.2 节所述, 这一行为与 glibc 库对系统调用 clone()的包装函数行为相同。 )

    将参数 arg 声明为 void*类型,意味着可以将指向任意对象的指针传递给 start()函数。一般情况下, arg 指向一个全局或堆变量,也可将其置为 NULL。如果需要向 start()传递多个参数,可以将 arg 指向一个结构,该结构的各个字段则对应于待传递的参数。通过审慎的类型强制转换, arg 甚至可以传递 int 类型的值。 严格说来,对于 int 与 void*之间相互强制转换的后果, C 语言标准并未加以定义。不过,大部分 C 语言编译器允许这样的操作,并且也能达成预期的目的,即 int j == (int) ((void*) j) start()的返回值类型为 void*,对其使用方式与参数 arg 相同。对后续 pthread_join()函数的描述中,将论及对该返回值的使用方式。 将经强制转换的整型数作为线程 start 函数的返回值时,必须小心谨慎。原因在于,取消线程(见第 32 章)时的返回值 PTHREAD_CANCELED,通常是由实现所定义的整型值,再经强制转换为 void*。

    若线程某甲的 start 函数将此整型值返回给正在执行 pthread_join()操作的线程某乙,某乙会误认为某甲遭到了取消。应用如果采用了线程取消技术并选择将 start 函数的返回值强制转换为整型,那么就必须确保线程正常结束时的返回值与当前 Pthreads 实现中的PTHREAD_CANCELED 不同。如欲保证程序的可移植性,则在任何将要运行该应用的实现中,

    正常退出线程的返回值应不同于相应的 PTHREAD_CANCELED 值。 参数 thread 指向 pthread_t 类型的缓冲区,在 pthread_create()返回前,会在此保存一个该线程的唯一标识。后续的 Pthreads 函数将使用该标识来引用此线程。 SUSv3 明确指出,在新线程开始执行之前,实现无需对 thread 参数所指向的缓冲区进行初始化,即新线程可能会在 pthread_create()返回给调用者之前已经开始运行。如新线程需要获取自己的线程 ID,则只能使用pthread_self()方法。

    参数 attr 是指向 pthread_attr_t 对象的指针,该对象指定了新线程的各种属性。 29.8 节将述及其中的部分属性。如果将 attr 设置为 NULL,那么创建新线程时将使用各种默认属性,本书的大部分示例程序都采用这一做法。

    调用 pthread_create()后,应用程序无从确定系统接着会调度哪一个线程来使用 CPU 资源。程序如隐含了对特定调度顺序的依赖,则无疑会对 24.4 节所述的竞争条件打开方便之门。如果对执行顺序确有强制要求,那么就必须采用第 30 章所描述的同步技术

    29.4 终止线程

    可以如下方式终止线程的运行。

    1.线程 start 函数执行 return 语句并返回指定值。

    2.线程调用 pthread_exit()(详见后述)。

    3.调用 pthread_cancel()取消线程(在 32.1 节讨论)。

    4.任意线程调用了 exit(),或者主线程执行了 return 语句(在 main()函数中),都会导致进程中的所有线程立即终止。 pthread_exit()函数将终止调用线程,且其返回值可由另一线程通过调用 pthread_join()来获取。

    #include<pthread.h>
    void pthread_exit(void *retval);

    调用 pthread_exit()相当于在线程的 start 函数中执行 return,不同之处在于,可在线程 start函数所调用的任意函数中调用 pthread_exit() 。 参数 retval 指定了线程的返回值。 Retval 所指向的内容不应分配于线程栈中,因为线程终止后,将无法确定线程栈的内容是否有效。(例如,系统可能会立刻将该进程虚拟内存的这片区域重新分配,供一个新的线程栈使用。)出于同样的理由,也不应在线程栈中分配线程 start 函数的返回值。 如果主线程调用了 pthread_exit(),而非调用 exit()或是执行 return 语句,那么其他线程将继续运行。

    29.5 线程 ID(Thread ID)

    进程内部的每个线程都有一个唯一标识,称为线程 ID。线程 ID 会返回给 pthread_create()的调用者,一个线程可以通过 pthread_self()来获取自己的线程 ID。

    #include<pthread.h>
    pthread_t pthread_self(void);

    线程 ID 在应用程序中非常有用,原因如下。 1.不同的Pthreads 函数用线程 ID 来标识要操作的目标线程。这些函数包括 pthread_join()、pthread_detach()、 pthread_cancel()和 pthread_kill()等,后续章节将会加以讨论。 2.在一些应用程序中,以特定线程的线程 ID 作为动态数据结构的标签,这颇有用处,既可用来识别某个数据结构的创建者或属主线程,又可以确定随后对该数据结构执行操作的具体线程。 函数 pthread_equal()可检查两个线程的 ID 是否相同。

    #include<pthread.h>
    int pthread_equal(pthread_t t1,pthread_t t2);

    例如, 为了检查调用线程的线程 ID 与保存于变量 t1 中的线程 ID 是否一致, 可以编写如下代码: 因为必须将 pthread_t 作为一种不透明的数据类型加以对待, 所以函数 pthread_equal()是必须的。 Linux 将 pthread_t 定义为无符号长整型( unsigned long),但在其他实现中,则有可能是一个指针或结构。

    SUSv3 并未要求将 pthread_t 实现为一个标量( scalar)类型,该类型也可以是一个结构。因此,下列显示线程 ID 的代码实例并不具有可移植性(尽管该实例在包括 Linux 在内的许多实现上均可正常运行,而且有时在调试程序时还很实用)。

    在 Linux 的线程实现中,线程 ID 在所有进程中都是唯一的。不过在其他实现中则未必如此, SUSv3 特别指出,应用程序若使用线程 ID 来标识其他进程的线程,其可移植性将无法得到保证。此外,在对已终止线程施以pthread_join(),或者在已分离( detached)线程退出后,实现可以复用该线程的线程 ID。 (下一节和 29.7 节将分别解释 pthread_join()和线程的分离。 )

    29.6 连接(joining)已终止的线程

    函数 pthread_join()等待由 thread 标识的线程终止。 (如果线程已经终止, pthread_join()会立即返回)。这种操作被称为连接(joining)

    #include<pthread.h>
    int pthread_join(pthread_t thread,int retval);

    若 retval 为一非空指针,将会保存线程终止时返回值的拷贝,该返回值亦即线程调用return 或 pthred_exit()时所指定的值。 如向 pthread_join()传入一个之前已然连接过的线程 ID,将会导致无法预知的行为。例如,相同的线程 ID 在参与一次连接后恰好为另一新建线程所重用,再度连接的可能就是这个新线程。 若线程并未分离(detached,见 29.7 节),则必须使用 ptherad_join()来进行连接。如果未能连接,那么线程终止时将产生僵尸线程,与僵尸进程(zombie process)的概念相类似(参考 26.2节)。

    除了浪费系统资源以外,僵尸线程若累积过多,应用将再也无法创建新的线程。 pthread_join()执行的功能类似于针对进程的 waitpid()调用,不过二者之间存在一些显著差别。 1.线程之间的关系是对等的( peers)。进程中的任意线程均可以调用 pthread_join()与该进程的任何其他线程连接起来。 例如, 如果线程 A 创建线程 B, 线程 B 再创建线程 C,那么线程 A 可以连接线程 C, 线程 C 也可以连接线程 A。 这与进程间的层次关系不同,父进程如果使用 fork()创建了子进程,那么它也是唯一能够对子进程调用 wait()的进程。调用 pthread_create()创建的新线程与发起调用的线程之间,就没有这样的关系。 2.无法“连接任意线程”(对于进程,则可以通过调用 waitpid(-1, &status, options)做到这一点),也不能以非阻塞( nonblocking)方式进行连接(类似于设置 WHOHANG 标志的 waitpid())。使用条件( condition)变量可以实现类似的功能, 30.2.4 节会给出示例。

    29.7 线程的分离

    默认情况下,线程是可连接的(joinable),也就是说,当线程退出时,其他线程可以通过调用 pthread_join()获取其返回状态。有时,程序员并不关心线程的返回状态,只是希望系统在线程终止时能够自动清理并移除之。在这种情况下,可以调用 pthread_detach()并向 thread 参数传入指定线程的标识符,将该线程标记为处于分离状态。

    #include<pthread.h>
    int pthread_detach(pthread_t thread);

    如下例所示,使用 pthread_detach(),线程可以自行分离:

    pthread_detach(pthread_self())

    一旦线程处于分离状态,就不能再使用 pthread_join()来获取其状态,也无法使其重返“可连接”状态。 其他线程调用了 exit(),或是主线程执行 return 语句时,即便遭到分离的线程也还是会受到影响。此时,不管线程处于可连接状态还是已分离状态,进程的所有线程会立即终止。

    换言之, pthread_detach()只是控制线程终止之后所发生的事情,而非何时或如何终止线程。

    29.8 线程属性

    前面已然提及 pthread_create()中类型为 pthread_attr_t 的 attr 参数, 可利用其在创建线程时指定新线程的属性。本书无意深入这些属性的细节,也不会将操作 pthread_attr_t 对象的各种 Pthreads 函数原型一一列出, 只会点出如下之类的一些属性:

    线程栈的位置和大小、

    线程调度策略和优先级

    以及线程是否处于可连接或分离状态。 作为线程属性的使用示例,程序清单 29-2 中的代码创建了一个新线程,该线程刚一创建即遭分离(而非之后再调用 pthread_detach())。这段代码首先以缺省值对线程属性结构进行初始化,接着为创建分离线程而设置属性,最后再以此线程属性结构来创建新线程。线程一旦创建,就无需再保留该属性对象,故而程序将其销毁

    29.9 线程 VS 进程

    将应用程序实现为一组线程还是进程?本节将简单考虑一下可能影响这一决定的部分因素。先从多线程方法的优点开始。 1.线程间的数据共享很简单。相形之下,进程间的数据共享需要更多的投入。 (例如,创建共享内存段或者使用管道 pipe)。 2.创建线程要快于创建进程。线程间的上下文切换( context-switch),其消耗时间一般也比进程要短。

    线程相对于进程的一些缺点如下所示。

    1.多线程编程时,需要确保调用线程安全( thread-safe)的函数,或者以线程安全的方式来调用函数。 多进程应用则无需关注这些。

    2.某个线程中的 bug(例如,通过一个错误的指针来修改内存)可能会危及该进程的所有线程, 因为它们共享着相同的地址空间和其他属性。 相比之下, 进程间的隔离更彻底。

    3.每个线程都在争用宿主进程( host process)中有限的虚拟地址空间。特别是,一旦每个线程栈以及线程特有数据(或线程本地存储)消耗掉进程虚拟地址空间的一部分,则后续线程将无缘使用这些区域。虽然有效地址空间很大,但当进程分配大量线程,亦或线程使用大量内存时,这一因素的限制作用也就突显出来。与之相反,每个进程都可以使用全部的有效虚拟内存,仅受制于实际内存和交换( swap)空间。

    影响选择的还有如下几点。 1.在多线程应用中处理信号,需要小心设计。 (作为通则,一般建议在多线程程序中避免使用信号。 )关于线程与信号, 33.2 节会做深入讨论。 2.在多线程应用中,所有线程必须运行同一个程序(尽管可能是位于不同函数中)。对于多进程应用,不同的进程可以运行不同的程序。 3.除了数据,线程还可以共享某些其他信息(例如,文件描述符、信号处置、当前工作目录,以及用户 ID 和组 ID)。优劣之判,视应用而定。

  • 相关阅读:
    Swift语言概览
    玩转可视化--来聊聊地图投影的学问
    网易云易盾朱浩齐:视听行业步入强监管和智能时代
    知物由学 | 人工智能、机器学习和深度学习如何在网络安全领域中应用?
    数据分析怎么更直观?十分钟构建数据看板
    网易云易盾朱星星:最容易被驳回的10大APP过检项
    知物由学|游戏开发者如何从容应对Unity手游风险?
    知物由学 | 这些企业大佬如何看待2018年的安全形势?
    4月第4周业务风控关注 | 网络犯罪经济每年1.5万亿美元 GDP居全球第12位
    工信部公示网络安全示范项目 网易云易盾“自适应DDoS攻击深度检测和防御系统”入选
  • 原文地址:https://www.cnblogs.com/wangbin2188/p/14805896.html
Copyright © 2011-2022 走看看