CSDN 上的 APUE读书笔记之第十一章 -- 线程

20 篇文章 0 订阅

第十一章 线程

线程机制引入 Unix 家族的时间相对比较晚,标准化后称为 POSIX Threads(以下简称线程),使用的库称为 pthreads(7)。它提供了在一个进程中并行地执行多个任务的机制。有助于将一个程序清晰的分解成多个不同的独立部分,例如用一个线程专门处理信号,用一个线程专门处理异步事件,再用一个线程专门负责提供服务等等。同一个进程内的线程可以无限制的共享进程的数据资源。使用多线程也可以节省生成新进程时的系统开销,在一定程度上改善响应时间。

另一方面,线程对进程资源无障碍的共享也带来了并发和同步的问题,而且只要任一线程异常退出就会导致整个进程的崩溃。线程安全编程将增加程序的复杂性,同时也更难于调试,往往容易成为bug丛生的地方。另外由于现代的Unix-like 系统(尤其是Linux)普遍采用了COW等减小进程生成开销的技术,使得很多时候线程的实现几乎已经没有作为“轻量级进程”的优势。而ESR 在他的TAOUP 一书中认为:“线程是那些进程生成昂贵、IPC功能薄弱的操作系统所特有的概念”,认为线程带来的好处与引入的麻烦相比得不偿失。

当然,这只是Unix 老炮的一家之见。SMP 架构以及基于网络的应用程序设计依然一直是线程编程的传统领域,前人有多年积累的实践经验。Programming with POSIX Threads、Multi-threaded Programming Guide 以及 UNIX Network Programming Volume 2: Interprocess Communications 等都是经典的 POSIX 线程编程参考资料。另外 Gentoo 创始人 Daniel Robbins 写的这篇文章也是经典的 POSIX 线程开发的入门资料。

pthreads(7)库的实现采用类似面向对象的方式,线程对象、线程属性对象、线程同步原语对象等都采用了不透明的数据类型,并各自有其访问方法,不能使用其它原有的POSIX API 去访问。APUE 在其第一版中本来没有讲述线程编程的章节。Stevens 去世后,后人在更新第二版时补充了两章多线程编程的章节。

本章讲述了线程的基本概念,包括线程的创建、撤销和同步原语,基本的线程数据类型及其访问方法等。下一章讲述线程各基本类型的属性及访问属性的方法,以及线程的交互。

1、线程 ID 类型的访问方法

取当前 TID 的函数是:

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

对两个 TID 进行比较(不应直接使用逻辑运算符)的函数是:

#include <pthread.h>
int pthread_equal(pthread_t tid1, pthread_t tid2);

2、线程的创建

#include <pthread.h>
int pthread_create(pthread_t *restrict tidp, const pthread_attr_t *restrict attr, void *(*start_rtn)(void), void *restrict arg);
该函数用于创建一个新的线程,其TID 放在 tidp 指向的地址,它的属性放在attr 指向的地址,该线程将函数指针 start_rtn 处(启动例程)开始执行,并引用 arg 指向的向量表作为函数参数。

线程机制各种数据类型的属性使用方法在下一章中详述。

几个需要注意的地方:

  •  函数指针 start_rtn 指向一个指针函数,即启动例程必须返回一个指针。 
  •  是新线程还是原来的线程先运行事先无法假设;新线程可能在原线程的 pthread_create 返回前就开始运行;  
  • 新的线程共享同一进程的所有地址空间、信号屏蔽字等,但该线程的未决信号集将被清除,这意味着新线程是不能捕捉创建之前产生的信号的;  
  • prthread 库函数调用失败时通常不修改 errno,而是返回错误代码;  
  • Linux 的线程事实上和 fork(2)一样通过执行 clone(2)系统调用实现的,只是调用参数不一样,使得子进程有和父进程共享内存空间等的轻量级进程的特点。对于 Linux 线程机制的实现细节,可以参考Understanding Linux Kernel, 3rd Edition 的第三章。

3、线程的终止

终止一个线程的终止有多种方式:

  •  线程通过调用 exit(3)、_exit(2)、_Exit(2)等终止整个进程;  
  • 线程从启动例程返回;  
  • 线程通过调用 pthread_exit(3)退出;

#include <pthread.h>
void pthread_exit(void *rval_ptr);
其中,指针 rval_ptr 处可以放置退出时要传递的相关信息。其它函数通过 pthread_join(3)访问这个指针:

#include <pthread.h>
int thread_join(pthread_t thread, void **rval_ptr);
该函数以类似 wait(2) 的方式阻塞并等待指定的线程thread 退出,并将其退出时的信息保存到rval_ptr 指向的指针处;

若线程从启动例程返回,则退出信息指针为该例程的返回值;若调用了pthread_exit,则为其参数;若被其它线程取消,则为常数 PTHREAD_CANCELED;

要特别注意的是,pthread_exit 的指针参数不能是一个局部变量,否则在线程结束时该段内存会被回收,其它线程pthread_join 的时候就会出错;

还可以使用pthread_detach(3)分离线程,此时就不用也不能join 它的退出状态。

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

  • 线程被同一进程的其它进程取消;
#include <pthread.h>
int pthread_cancel(pthread_t tid);

该函数用于取消指定的线程,它发出终止请求后就立即返回,并不检查指定的线程是否已经终止;

函数 pthread_cleanup_push 使用类似 atexit(3)的方式注册一个线程终止时的清理函数。

#include <pthread.h>
void pthread_cleanup_push(void (*rtn)(void *), void *arg);

可以通过调用 pthread_exit(3), 响应 pthread_cancel(3)的请求,或者调用函数 pthread_cleanup_pop来隐式或显示的调用已注册的线程清理函数。(但是从启动例程中返回不会调用清理函数)

#include <pthread.h>
void pthread_cleanup_pop(int execute); 

该函数用于显式的解除注册最近一个注册的线程清理函数,即线程清理函数使用类似栈的压(push)、弹(pop)方式进行注册和解除;参数 execute 为 0 时表示仅解除注册,而不执行,非 0 则表示执行该清理函数并解除注册;

要特别注意的是,pthread_cleanup_push(3)和 pthread_cleanup_pop(3)可能是通过宏来实现的,必须在同一个缩进级别的代码块中配对使用,即编写代码时不能只调用其中一个,否则编译将通不过。因为它们的宏实现通常使用了单的花括号,详见相关手册页。


4、线程的同步

多个任务(多个进程,或者同一个进程中的多个线程等等)同时访问共有资源时,特别是在异步操作方式下,很容易由于微观上的乱序而产生并发问题,使得对资源的访问结果不可控。故需要采取同步方法去解决并发,使微观层面上的各个任务对共有资源的操作实现串行化,从而互不冲突地有序使用资源。

一般使用锁机制来实现同步的方法。锁机制是一种抽象概念,简单说就是:使占有锁的任务可以自由访问资源,没有锁的任务则无法访问资源,除非该锁被释放并被其占有。锁又分为建议性锁(advisory lock)和强制性锁(mandatory lock)。强制性锁一般跟操作系统及文件系统的实现有关,以下提及的锁,除非特别提及,否则都是建议性锁。

锁机制通常以对临界区(critical section)的保护来实现,即:使用临界区前测试锁,锁空闲时占用之,否则阻塞到锁可以被占用,离开临界区时释放锁。

使用锁机制进行编程有较强的技巧性,是并发程序设计中最复杂,最容易产生 bug 的环节,需要充分的考虑和经验积累。在使用锁机制时,“需要锁什么”这个问题一定要考虑清楚。各个并发的任务对锁的使用要保证遵循一致的资源访问规则,即所谓合作(cooperating)。在不合作的任务中,(建议性)锁是不起作用的。

锁的粒度如果设计的过粗,就会更容易使得过多的(饥饿)任务阻塞在锁上;如果设计过细,过多的加锁解锁开销会影响性能,同时增加程序的复杂性。

最后要特别注意死锁的问题。死锁简单说就是占有A锁的任务阻塞在B 锁上,同时占有B锁的任务阻塞在 A锁上,导致两个任务都永远无法继续向下执行。产生死锁的原因是多个锁加锁的顺序不一致产生的。避免死锁的措施是:各个任务应严格以相同的顺序加锁,如果出现不同的顺序,应先解开导致不一致顺序的锁,再重新按顺序加锁。

POSIX 线程实现了互斥量(mutex)、读写锁(rwlock)和条件变量(condition variable)的锁机制,用于实现线程同步。

由于这些锁用于线程同步编程,所以通常都通过在堆上动态分配(类似malloc(3))或者使用静态方法分配内存空间,而不应定义为作用域有限的局部变量。使用动态方法分配锁对象的内存空间时,通常会对锁对象设计一个引用计数器,以决定什么时候可以销毁此对象;

A. 互斥量

通过互斥量加锁的线程,在原子时间内,只有一个线程可以占有锁,其它试图占有锁的线程将被阻塞。

使用动态方法(即锁对象通过类似 malloc(3)的方法在堆上分配空间)创建和销毁互斥量的函数是

#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);
如果使用静态方法分配的互斥量,直接给互斥量对象赋值PTHREAD_MUTEX_INITIALIZE即可:

mutex = PTHREAD_MUTEX_INITIALIZE;
以下函数用于对互斥量对象加锁和解锁:

#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
第一个函数对锁 mutex 进行测试,在其未被占有时占有之,否则阻塞到该锁可以被当前线程占有为止;

第二个函数在 mutex 被占有时不会阻塞,而是返回 EBUSY,故应通过测试其返回值来保护临界区;

最后一个函数用于释放对mutex 的占用;


B. 读写锁

读写锁又称共享-独占锁。使用“读”模式占有锁时,对资源的访问为共享状态,将允许任何以“读”模式占有锁访问资源的操作,但阻塞任何试图以“写”模式占有锁访问资源的操作;使用“写”模式占有锁时,对资源的访问为独占状态,此时阻塞任何对此锁的占有要求。

简单点说,就是:读模式下只阻塞写模式锁,写模式下阻塞全部的锁。

另外,很多读写锁都有这样的实现:在使用读模式占有锁时,如果有写模式锁的请求被阻塞,则锁被释放之前,任何对锁的访问请求都将被阻塞。

创建和销毁一个动态分配的读写锁类似 mutex:

#include <pthread.h>
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
同样可以通过赋值初始化一个静态的读写锁:

rwlock = PTHREAD_RWLOCK_INITIALIZE;
读锁和写锁使用的函数接口不同,但解锁的接口是相同的:

#include <pthread.h>
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_rwlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

不阻塞地测试上锁的函数为:

#include <pthread.h>
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_tryrwlock(pthread_rwlock_t *rwlock);

C. 条件变量

条件变量的实现机制有点类似信号,它使线程直接在条件cond 上睡眠,直到被唤醒为止。条件变量常与互斥量配合使用,在条件不满足时暂时放弃占有互斥量,以减少因竞争锁资源而带来的饥饿;

创建和销毁一个动态分配的条件变量的接口为:

#include <pthread.h>
int pthread_cond_init(pthread_cond_t *restrict cond, pthread_condattr_t *restrict *attr);
int pthread_cond_destroy(pthread_cond_t *cond);

如果使用静态的条件变量,可以直接赋予一个初始化宏,如:

cond = PTHREAD_COND_INITIALIZE;

在占有某个互斥量的锁时,如果需要等待某个条件满足,可以使用函数 pthread_cond_wait(3),该函数以原子方式阻塞线程同时释放指定的 mutex,直到被唤醒,此时函数重新尝试占有 mutex(此时如果mutex 已经被其它线程占有的话会被阻塞)并返回。还可以使用 pthread_cond_timedwait(3)指定等待的时间,超时时线程直接被唤醒尝试重新占有 m`utex 并返回。

#include <pthread.h>
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict *restrict mutex, const struct timespec *restrict timeout);

条件满足时用于唤醒等待条件而阻塞的线程的函数为:

#include <pthread.h>
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
pthread_cond_signal 用于通知排队等待 cond 的第一个线程, pthread_cond_broadcast 则用于通知等待 cond 的所有线程。

要注意条件变量本身只是一个实现等待与唤醒的一个机制,具体等待的是什么条件需要自己在程序中设计定义。另外,如果条件在 wait 之前已经满足并发出信号通知,则线程在 wait 的时候可能会永远等待下去,这点在设计时要特别注意避免。




评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值