天天看点

进程控制线程控制

线程控制

12.1 简介

线程控制主要涉及线程属性、同步原语属性、同一线程中多个线程之间如何保持数据的私有性、基于进程的系统调用如何与线程进行交互等内容。

12.2 线程限制

Sysconf函数可以查询相关的线程限制。其具体内容如下表格:

限制名称 描述 Name参数
PTHREAD_DESTRUCTOR_ITERATIONS 线程退出时操作系统实现试图销毁线程特定数据的最大次数 _SC_THREAD_DESTRUCTOR_ITERATIONS
PTHREAD_KEYS_MAX 进程可以创建的键的最大数目 _SC_THREAD_KEYS_MAX
PTHREAD_STACK_MIN 一个线程的栈可用的最小字节数 _SC_THREAD_STACK_MIN
PTHREAD_THREADS_MAX 进程可以创建的最大线程数 _SC_THREAD_THREADS_MAX

线程的限制的使用是为了增强应用程序在不同的操作系统实现之间的可移植性。

12.3 线程属性

Pthread接口允许我们通过设置每个对象关联的不同属性来细调线程和同步对象的行为。通常,管理这些属性的函数都遵循相同的模式。

每个对象与它自己类型的属性进行关联(线程与线程属性关联,互斥量与互斥量属性关联等等)。一个属性对象可以代表多个属性。属性对象对应用程序来说是不透明的。这意味着应用程序并不需要了解有关属性对象内部结构的详细细节,这样可以增强应用程序的可移植性。取而代之的是,需要提供相应的函数来管理这些属性对象。

有一个初始化函数,把属性设置为默认值。

还有一个销毁属性对象的函数。如果初始化函数分配了与属性对象关联的资源,销毁函数负责释放这些资源。

每个属性都有一个从属性对象中获取属性值的函数。由于函数成功时会返回0,失败时会返回错误编号,所以可以通过把属性值存储在函数的某一个参数指定的内存单元中,把属性值返回给调用者。

每个属性都有一个设置属性值的函数。在这种情况下,属性值作为参数按值传递。

对进程来说,虚地址空间的大小是固定的。因为进程只有一个栈,所以他的大小通常不是问题。但对于线程来说,同样大小的虚地址空间必须被所有的线程栈共享。如果应用程序使用了许多线程,以致这些线程栈的累计大小超过了可用的虚拟地址空间,就需要减少默认的线程栈大小。另一方面,如果线程调用的函数分配了大量的自动变量,或者调用的函数涉及许多很深的栈帧(stack frame),那么需要的栈大小可能要比默认的大。

如果线程栈的虚地址空间都用完,则可以使用malloc和mmap来为可替代的栈分配空间,并调用pthread_attr_setstack函数来改变新建线程的栈位置。

12.4 同步属性

就像线程具有属性一样,线程的同步对象也有属性。如互斥量属性、读写锁属性、条件变量属性、屏障属性等。

  • 互斥量属性

    值得注意的属性有三个:进程共享属性、健壮属性以及类型属性。

    进程共享属性是可选的。存在这样的机制:允许相互独立的多个进程把同一个内存数据映射到它们各自独立的地址空间中。就像多个进程访问共享数据一样,多个进程访问共享数据通常也需要同步。

    互斥量健壮属性与在多个进程间共享的互斥量有关。这意味着,当持有互斥量的进程终止时,需要解决互斥量状态恢复的问题。这种情况发生时,互斥量处于锁定状态,恢复起来很困难。其他阻塞在这个锁的进程将会一直阻塞下去。

  • 读写锁属性

    读写锁与互斥量类似,也有属性。读写锁支持的唯一属性是进程共享属性。它与互斥量的进程共享属性是相同的。就向互斥量的进程共享属性一样,有一对函数用于读取和设置读写锁的进程共享属性。

  • * 条件变量属性*

    Single UNIX Specification目前定义了条件变量的两个属性:进程共享属性和时钟属性。与其他的属性对象一样,有一对函数用于初始化和反初始化条件变量的属性。

    与其他的同步属性一样,条件变量支持进程共享属性。它控制着条件变量是可以被单进程的多个线程使用,还是可以被多进程的线程使用。

    时钟属性,注意是条件变量的超时参数中所使用的。但注意:Single UNIX Specification并没有为其他有超时等待函数的属性对象定义时钟属性。

  • 屏障属性

    屏障也有属性。目前定义的屏障属性只有进程共享属性,它控制这屏障可以被多进程的线程使用,还是只能被初始化屏障的进程内的多线程使用。

12.5 重入

线程在遇到重入问题时与信号处理程序是类似的。在这两种情况下,多个控制线程在相同的时间有可能调用相同的函数。

如果一个函数在相同的时间点可以被多个线程安全地调用,就称该函数是线程安全的。

操作系统支持线程安全函数特性。很多函数并不是线程安全的,因为他们返回的数据存放在静态的内存缓冲区中。通过修改接口,要求调用者自己提供缓冲区可以使函数变为线程安全。

如果一个函数对多个线程来说是可重入的,就说这个函数是线程安全的。但这并不能说明对信号处理程序来说该函数也是可重入的。如果函数对异步信号处理程序的重入是安全的,那么就可以说函数是异步信号安全的。

由于pthread函数并不保证是异步信号安全的,所以不能把pthread函数用于其他函数,让该函数称为异步信号安全的。

12.6 线程特定数据

线程特定数据(thread-specific data),也称为线程私有数据(thread-private data),是存储和查询某个特定线程相关数据的一种机制。把这种数据称为线程特定数据或线程私有数据的原因是,希望每个线程可以访问它自己单独的数据副本,而不需要担心与其他线程的同步访问问题。

线程模型促进了进程中数据和属性的共享,许多人在设计线程模型时会遇到各种麻烦。那么为什么有人想在这样的模型中促进阻止共享的接口呢?其原因如下:

有时候需要维护基于每个线程(per-thread)的数据。因为线程ID并不能保证是小而连续的整数,所以就不能简单的分配一个每线程数据数组,用线程ID作为数组索引。即使线程ID确实是小而连续的整数,可能还希望有一些额外的保护,防止某个线程的数据与其他线程的数据相混淆。

线程私有数据提供了让基于进程的接口适应多线程环境的机制。典型举例是errno。以前的接口(线程出现以前)把errno定义为进程上下文中全局可访问的整数。系统调用和库例程在调用或执行失败时设置errno,把它作为操作失败时的附属结果。为了让线程也能够使用那些原本基于进程的系统调用和库例程,errno被重新定义为线程私有数据。这样,一个线程做了重置errno的操作不会影响进程中其他线程的errno值。

一个进程中的所有线程都可以访问这个进程的整个地址空间。除了使用寄存器以外,一个线程没办法阻止另一个线程访问它的数据。线程特定数据也不例外。虽然底层的实现部分并不能阻止这种访问能力,但管理线程特定数据的函数可以提高线程访问的数据独立性,使得线程不太容易访问到其他线程的线程特定数据。

在分配线程特定数据之前,需要创建与该数据关联的键。这个键将用于获取对线程特定数据的访问。创建的键存储在内存单元中,这个键可以被进程中的所有线程使用,但每个线程把这个键与不同的线程特定数据地址进行关联。创建新键时,每个线程的数据地址都设为空值。此外,线程还为该键关联一个析构函数,用于释放内存等。线程可以为线程特定数据分配多个键,每个键都可以有一个析构函数与它关联。每个键的析构函数可以互不相同,当然,所有的键也可以使用相同的析构函数。每个操作系统可以对进程可分配的键的数量进行限制。

12.7 取消选项

线程取消选项的调用并不等待线程终止。默认情况下,线程在取消请求发出以后还是继续运行,直到线程到达某个取消点。取消点是线程检查它是否被取消的一个位置,如果取消了,则按照请求行事。默认的取消选项是推迟取消。

异步取消与推迟取消不同,因为使用异步取消时,线程可以在任意时间撤销,不是非得遇到取消点才能被取消。

12.8 线程和信号

即使是在基于进程的编程规范中,信号的处理有时候也是很复杂的。把线程引入编程规范,就使信号的处理变得更加复杂。

每个线程都有自己的信号屏蔽字,但是信号的处理是进程中所有线程共享的。这意味着单个线程可以阻止某种信号,但当某个线程修改了与某个给定信号相关的处理行为后,所有的线程都必须共享这个处理行为的改变。这样,如果一个线程选择忽略某个给定信号,那么另一个线程就可以通过以下两种方式撤销线程的信号选择:恢复信号的默认处理行为,或者为信号设置一个新的信号处理程序。

进程中的信号是递送到单个线程的。如果一个信号与硬件故障相关,那么该信号一般会被发送到引起该事件的线程中去,而其他的信号则被发送到任意一个线程。

为了防止信号中断线程,可以把信号加到每个线程的信号屏蔽字中。然后可以安排专用线程处理信号。这些专用线程可以进行函数调用,不需要担心在信号处理程序中调用哪些函数是安全的,因为这些函数调用来自正常的线程上下文,而非会中断线程正常执行的传统信号处理函数。

闹钟定时器是进程资源,并且所有的线程共享相同的闹钟。所以,进程中的多个线程不可能互不干扰(互不合作)地使用闹钟定时器。当创建线程进行信号处理时,新建线程继承了现有的信号屏蔽字。

12.9 线程和fork

当线程调用fork时,就是为子进程创建了整个进程地址空间的副本。*为写时复制,子进程与父进程是完全不同的进程,只要两者都没有对内存内容作出改动,父进程和子进程之间还可以共享内存的副本。*

子进程通过继承整个地址空间的副本,还从父进程那儿继承了每个互斥量,读写锁和条件变量的状态。如果父进程包含一个以上的进程,子进程在fork返回以后,如果紧接着不是马上调用exec的话,就需清理锁的状态。

在子进程内部,只有一个线程,它是由父进程中调用fork的线程的副本构成的。如果父进程中的线程占有锁,子进程将同样占有这些锁。问题是子进程并不包含占有锁的线程的副本,所以子进程没有办法知道它占有了哪些锁、需要释放哪些锁。

如果子进程从fork返回以后马上调用其中一个exec函数,就可以避免这样的问题。这种情况下,旧的地址空间就被丢弃,所以锁的状态无关紧要。但如果子进程需要继续做处理工作的话,这种策略就行不通,还需要使用其他策略。

在多线程的进程中,为了避免不一致状态的问题,在fork返回和子进程调用其中一个exec函数之间,子进程只能调用异步信号安全的函数。这就限制了在调用exec之前子进程能做什么,但不涉及子进程中锁状态的问题。

要清除锁状态,可以通过调用pthread_atfork函数建立fork处理程序。

用pthread_atfork函数最多可以安装3个帮助清理锁的函数。Prepare fork处理程序由父进程在fork创建子进程前调用。这个fork处理程序的任务是获取父进程定义的所有锁。Parent fork处理程序是在fork创建子进程以后、返回之前在父进程上下文中调用。这个fork处理程序的任务是对prepare fork处理程序获取的所有锁进行解锁。Child fork处理程序在fork返回之前在子进程上下文中调用。与parent fork处理程序一样,child fork处理程序也必须释放prepare fork处理程序获取的所有锁。

注意,不会出现加锁一次解锁两次的情况,虽然看起来也许会出现。子进程地址空间在创建时就得到了父进程定义的所有锁的副本。因为prepare fork处理程序获取了所有的锁,父进程中的内存和子进程中的内存内容在开始的时候是相同的。当父进程和子进程对它们锁的副本进程解锁的时候,新的内存是分配给子进程的,父进程的内存内容是复制给子进程的内存中(写时复制),所以就会陷入这样的假象,看起来父进程对它所有的锁的副本进行了加锁,子进程对它所有的锁的副本进行了加锁。父进程和子进程对不同内存单元的重复的锁都进行了解锁操作,就好像出现了下列事件序列:

父进程获取了所有的锁

子进程获取了所有的锁

父进程释放了它的锁

子进程释放了它的锁

**使用多个fork处理程序时,处理程序的调用顺序并不相同。**Parent和child fork处理程序是以它们注册时的顺序进行调用的,而prepare fork处理程序的调用顺序与它们注册时的顺序相反。这样可以允许多个模块注册它们自己的fork处理程序,而且可以保持锁的层次。

假设模块A调用模块B中的函数,而且每个模块有自己的一套锁。如果锁的层次式A在B之前,模块B必须在模块A之前设置它的fork处理程序。当父进程调用fork时,就会执行以下步骤,假设子进程在父进程之前运行:

调用模块A的prepare fork处理程序获取模块A的所有锁

调用模块B的prepare fork处理程序获取模块B的所有锁

创建子进程

调用模块B中的child fork处理程序释放子进程中模块B的所有锁

调用模块A中的child fork处理程序释放子进程中模块A的所有锁

Fork函数返回到子进程

调用模块B中的parent fork处理程序释放父进程中模块B的所有锁

调用模块A中的parent fork处理程序释放父进程中模块A的所有锁

Fork函数返回到父进程

如果fork处理程序是用来清理锁状态的,那么又由谁负责清理条件变量的状态呢?在有些操作系统的实现中,条件变量可能并不需要做任何清理。但是有些操作系统实现把锁作为条件变量实现的一部分,这种情况下的条件变量就需要清理。问题是目前不允许清理锁状态的接口。如果锁是嵌入到条件变量的数据结构中的,那么在调用fork之后就不能使用条件变量,因为换没有可移植的方法对锁进行状态清理。另外,如果操作系统的实现是使用全局锁保护进程中的所有的条件变量数据结构,那么操作系统实现本身可以在fork库例程中做清理的工作,但是应用程序不应该依赖操作系统实现中类似这样的细节。

虽然pthread_atfork机制的意图是使fork之后的锁状态保持一致,但还是存在一些不足之处,只能在有限情况下可用

没有很好的办法对较复杂的同步对象(如条件变量或者屏障)进行状态的重新初始化

某些错误检查的互斥量实现在child fork处理程序试图对被父进程加锁的互斥量进行解锁时会产生错误

递归互斥量不能在child fork处理程序中清理,因为没有办法确定该互斥量被加锁的次数

如果子进程只允许调用异步信息安全的函数,child fork处理程序就不可能清理同步对象,因为用于操作清理的所有函数都不是异步信号安全的。实际的问题是同步对象在某个线程调用fork时可能处于中间状态,除非同步对象处于一致状态,否则无法被清理。

如果应用程序在信号处理程序中调用了fork(这是合法的,因为fork本身是异步信号安全的),pthread_atfork注册的fork处理程序只能调用异步信号安全的函数,否则结果将是未定义的。

12.10 线程和I/O

Pread和pwrite函数,在多线程环境下非常有用,因为进程中所有线程共享相同的文件描述符。Pread函数可以解决并发线程对同一文件的读操作问题,pwrite函数可以解决并发线程对同一文件进行写操作的问题。

参考文献 :Unix环境高级编程

继续阅读