1、线程的概念 和 基础知识
1.1 什么是线程
线程可看作轻量级进程(light weight process),Linux的线程本质仍然是进程。Linux先有进程后有线程,当创建了一个进程时,系统给他分配一段4G的虚拟内存,并在其内生成进程的PCB,当他调用相关函数创建一个线程时,会为新的线程生成一个PCB也存放在当前的4G虚拟内存中,而原来的进程也沦为一个线程。
所以,进程和线程的区别是:是否共享地址空间。 进程总是独享4G的虚拟内存,而多个线程共享一段4G的空间。
线程是CPU调度的最小单位,也是CPU分配时间片的单位,所以,线程越多的应用程序获得CPU的概率也就越大,所以使用多线程能够提高程序的执行效率。而进程可看作只有一个线程的进程,所以单进程应用程序 与 多线程应用程序争夺CPU时并不占优势。
进程是资源分配的最小单位。 一个进程独占4G的虚拟内存,而同意进程创建的多个线程共同使用同一片4G的空间。
1.2 进程 和 线程的关系
类UNIX系统中,进程和线程关系密切。
创建线程使用的底层系统调用和进程一样,都是clone()
,只不过创建进程时需要新找一片4G空间,并从原来的4G空间拷贝大部分数据,而创建线程则不需要额外开辟地址空间。一个进程创建线程之后就蜕变为线程。
进程和(进程创建的)线程都有各自的PCB,但PCB中指向内存资源的三级页表(虚拟地址到物理地址的映射)是相同的。
1.3 线程共享的资源
① 文件描述符表
② 信号的处理方式
③ 当前工作目录
④ 用户ID 和 组ID
⑤ 全局变量
⑥ 虚拟内存地址空间:.text/.data/.bss/.heap/共享库(其实就是共享0-3G的空间,除了栈空间 和 errno
变量)
1.4 线程非共享资源
① 线程ID
② 处理器现场和栈指针(内核栈空间)
③ 独立的栈空间(用户栈空间)
④ errno
变量
⑤ 信号屏蔽字
⑥ 线程的调度优先级
1.5 线程的优缺点
优点:
① 提高程序并发性
② 开销小
③ 数据通信、共享数据方便(不同线程可以使用全局变量)
缺点:
① 线程使用第三方库函数,不稳定
② 代码不好调试,没法用gdb调试
③ 对信号的支持不好
2、线程控制原语
2.1 pthread_self
#inlcude<pthread.h>
pthread_t pthread_self(void); pthread_t 是 unsigned long 类型,打印的时候要用 %lu
此调用永远不会失败,返回调用的线程ID。线程ID用于在进程中区分不同线程。
2.2 pthread_create
#include<pthread.h>
int pthread_create(pthread_t* thread, const pthread_attr_t* attr, void* (*start_routine)(void*), void* arg);
该函数用于创建一个线程,thread
是传出参数,传出线程ID。第二个参数用于指定线程属性,传入NULL
表示使用默认属性。
第三个参数是个函数指针,是线程的主控函数,第四个参数是该函数的参数。第四个参数要强制转换成泛型(void*
)然后进行值传递即可,不能传递地址。因为子线程有自己的虚拟栈空间用于存放函数中的局部变量,如果去访问原来进程的地址空间,地址中存储的数据可能已经变了。
pthread_create
成功返回0,失败直接返回错误号,而不是-1。
注意:创建线程之后,有可能创建它的进程先退出了,那么;进程的存储空间将被回收,线程也就无法执行了。
2.3 pthread_exit
#include<pthread.h>
void pthread_exit(void* retval);
调用该函数的线程会直接退出,retval
表示线程的退出值,我们必须将该参数强转为泛型void*
。
pthread_exit()
、return
、exit()
的区别:
pthread_exit()
只表示退出当前线程。return
表示退出当前函数,当在main
函数中return
时,表示退出当前进程;当在线程中return
时,表示退出当前线程;当在线程调用的其他函数中return
时,只是退出那个函数。exit
无论在哪都表示退出当前进程。
编写多线程程序,一定要谨慎使用return
和 exit
,尽量使用pthread_exit
退出线程。
2.4 pthread_join
函数回收线程
线程中也存在僵尸线程,所以创建线程后必须用pthread_join
回收,其定义如下:
#include<pthread.h>
int pthread_join(pthread_t thread, void** retval);
该函数用于回收线程。任意线程都可以调用该函数来回收其他线程,这点与进程不同,子进程只能由父进程回收。
阻塞等待线程退出,获取线程的退出状态,即,pthread_exit
函数里的参数,若线程根本没有调用该函数,那么线程默认返回0退出。
thread
是我们要等待退出的线程的线程ID,retval
是传出参数,用于获取线程的退出值,即,pthread_exit
里的那个参数。
在使用该函数时要注意retval
参数:① 一定要用void**
强制转换为泛型指针 ② 该函数是将pthread_exit
里的退出值 复制到 retval
所指向的位置。③ 该参数可以置为NULL
,表示不需要获取线程退出值。
该函数成功返回0,失败返回错误号,可能的错误号如下:
2.5 pthread_detach
实现线程分离
线程分离状态: 处于线程分离状态的线程结束后,自己自动被回收(自动清理PCB),无需别的线程调用pthread_join
来回收,他们也不应该调用pthread_join
。
若进程有该机制,则不会产生僵尸进程。因为进程的资源都自动回收干净了,不存在残留。
pthread_cdtach
函数定义如下:
#include<pthread.h>
int pthread_detach(pthread_t thread);
thread
参数是要设置分离状态的线程ID。该函数成功返回0,失败返回错误号。
当一个线程被设置为分离状态后,就不能使用pthread_join
回收他了,如果调用了pthread_join
,则会返回错误号。
2.6 pthread_cancel
杀死线程
pthread_cancel
函数用于杀死线程,其定义如下:
#inlcude<pthread.h>
int pthread_cancel(pthread_t thread);
thread
是要杀死的线程ID。该函数成功返回0,失败返回错误号。
该函数要注意两点:
① 若一个线程被杀死,那么他的退出值是-1,使用pthread_join
获取到的退出值是-1。
② 当程序中执行到pthread_cancel
函数时,不会立即杀死相关线程,而是有一定延时。需要等到被杀死的线程自己运行到某个检查点(取消点)时,才真正执行杀死线程的动作。检查点基本都是系统调用,如printf
底层调用的write
、sleep
底层调用的pause
等。使用man 7 pthreads
查看所有的检查点。
pthread_testcancel
是一个库函数,他也是一个检查点,该函数调用总是成功的,我们可以调用该函数来人为设置一个检查点。
接收到杀死请求的目标线程可以决定是否允许被杀死,以及如何杀死,这分别由如下两个函数完成:
#include<pthread.h>
int pthread_setcancelstate(int state, int* oldstate);
int pthread_setcanceltype(int type, int* oldtype);
3、进程 和 线程的控制原语对比
fork() pthread_create()
exit() pthread_exit()
wait() pthread_join()
kill() pthread_cancel()
getpid() pthread_self()
4、线程属性
4.1 pthread_attr_t
结构体
线程的所有属性都封装在pthread_attr_t
结构体里,下面是它的定义:
typedef struct
{
int detachstate; 线程的分离状态
int schedpolicy; 线程调度策略
int schedparam; 线程调度参数
int inheritsched; 线程的继承性
int scope; 线程的作用域
size_t guardsize; 线程末尾的警戒缓冲区大小
int stackaddr_set; 线程的栈设置
void* stackaddr; 线程栈的位置
size_t stacksize; 线程栈的大小
}pthread_attr_t;
线程末尾的警戒缓冲区: 指两个线程各自栈空间之间的间隔区域,这个区域用于防止上面的线程发生栈溢出,从而影响到下面线程的栈空间。我们可以使用guardsize
来人为更改警戒缓冲区的大小。
线程栈的大小: 默认情况下,一个进程里的所有线程会均分整个进程的栈空间(8192Kb),我们可以人为设置它的大小。
4.2 设置线程属性
4.2.1 线程属性初始化
当你要自己设置线程的属性时,就需要使用如下函数:
#include<pthread.h>
int pthread_attr_init(pthread_attr_t* attr); 初始化线程属性
int pthread_attr_destroy(pthread_attr_t* attr); 销毁线程属性所占用的资源
这两个函数成对出现,有初始化就必有销毁。而且必须先初始化线程属性再调用pthread_create
创建线程。
这两个函数成功返回0,失败返回错误号。
4.2.2 设置线程分离状态
其实既可以设置分离状态,也可以获取线程是否处于分离状态:
#include<pthread.h>
int pthread_attr_setdetachstate(pthread_attr_t* attr, int detachstate); 设置
int pthread_attr_getdetachstate(const pthread_attr_t* attr, int* detachstate); 获取
参数 attr
都是已初始化的线程属性。
参数 detachstate
的值只有两种:
PTHREAD_CREATE_DETACHED 分离状态
PTHREAD_CREATE_JOINABLE 不分离状态
在设置函数中,该参数是一个传入参数;在获取函数中,该参数是一个传出参数,用于获取状态。
想要设置分离状态,必须在调用pthread_create
创建线程之前设置好。
两个函数成功返回0,失败返回错误号。
如果设置一个线程为分离状态,而这个线程运行又非常快,他可能在pthread_create
函数返回线程ID前就终止并自行回收了,他终止后可能将线程ID和系统资源移交给其他线程使用,这种情况下,调用pthread_create
就得到错误的线程ID。使用线程同步措施可以避免这种情况,方法之一就是调用pthread_cond_timedwait
函数(后面讲)。单不能使用wait
函数,他是使整个进程睡眠,并不能解决线程同步的问题。
5、线程同步
线程同步用来使多个线程对共享资源进行协调访问,使共享资源能够按照正常的逻辑被操作,而不是这个线程操作一会那个线程操作一会,造成共享数据被混乱访问,程序的执行结果未知。
5.1 互斥锁 mutex
(互斥量)
每个线程在对资源操作前都尝试先加锁,成功加锁才能操作,操作结束后解锁。由于锁只有一个,当一个线程持有锁后,其余线程拿不到锁,所以会阻塞等待。但Linux为我们提供的锁是建议锁,并不强制性线程使用锁,所以,如果又来一个线程不去加锁,而是直接访问全局变量,也能够访问,这样又会产生数据混乱。
5.1.1 互斥锁的相关函数
#include<pthread.h>
int pthread_mutex_init(pthread_mutex_t* restrict mutex, const pthread_mutexattr_t* restrict attr); 动态初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; 在全局作用域下,执行该语句与执行上面的函数等价,这种初始化方式称为静态初始化
int pthread_mutex_destroy(pthread_mutex_t* mutex);
int pthread_mutex_lock(pthread_mutex_t* mutex);
int pthread_mutex_trylock(pthread_mutex_t* mutex);
int pthread_mutex_unlock(pthread_mutex_t* mutex);
这五个函数成功返回0,失败返回错误号。
pthread_mutex_t
类型,本质是一个结构体类型。该类型的变量就是锁,所以它一般声明为全局变量,我们把它看成一个整数,只有0,1两种取值,分别表示上锁 和 没上锁。
pthread_mutex_init 函数
该函数用于初始化一个锁。声明一个pthread_mutex_t
锁之后不能立即使用,必须传他的地址到该函数里进行初始化(初始完是解锁状态)。第二个参数用于设置锁的属性,置为NULL
使用默认属性。
pthread_mutex_destroy 函数
该函数用于销毁一个锁。销毁后的锁就变成了一个未初始化的锁,所以可以重新调用pthread_mutex_init
初始化并投入使用。
可以销毁一个初始化但未锁定的锁,但销毁一个已锁定的锁、或 线程正在尝试锁定的锁,又或者另一个线程正在调用pthread_cond_timedwait
或pthread_cond_wait
的锁,都会导致未定义行为。
pthread_mutex_lock 函数
该函数用于锁定一个锁。可理解为令mutex
的值-1。若调用该函数时,锁的值已经是0,那么该线程会被阻塞,直到占用锁的线程解锁。
pthread_mutex_trylock 函数
该函数也用于锁定一个锁。但是该函数的锁定行为是非阻塞的,即,有其他线程锁定该锁时,该函数立即返回,不会等待哪个线程解锁。
pthread_mutex_unlock 函数
该函数用于解锁。
在访问共享资源前加锁,访问结束后 立即解锁 。锁的粒度越小越好。
5.1.2 死锁
① 一个线程对同一个互斥量加锁两次,那第二次加锁就会被阻塞,线程就卡在这里动不了了
解决方法:加锁后立即解锁
① 线程1拥有了A锁,请求获得B锁;而线程2获得了B锁,请求获得A锁
解决方法:其中一个线程在请求第二把锁的时候调用非阻塞版本,如果请求失败,那么放弃已经获取的锁,即,当不能获取所有锁时,放弃已经占有的锁。
③ 震荡
5.2 读写锁
读写锁也叫共享-独占锁,它用于对全局数据进行读写操作的情景。读写锁有三个状态,读模式下加锁(读锁),写模式下加锁(写锁),和 不加锁状态。
- 当锁处于读模式下加锁状态时,有其他线程也尝试以读模式状态加锁时,能够枷锁成功,其他线程以写模式试图加锁时被阻塞,即, 读共享 。
- 当锁处于写模式下加锁状态时,其他线程以任何模式尝试加锁都会被阻塞,即, 写独占 。
- 当锁处于不加锁状态时,既有尝试以读模式加锁的线程,又有尝试以写模式加锁的线程,那么写模式的线程会加锁成功,即, 写锁优先级高 。
读写锁的相关函数
#include<pthread.h>
int pthread_rwlock_init(pthread_rwlock_t* restrict rwlock, const pthread_rwlockattr_t* restrict attr);
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITITLIZER;
int pthread_rwlock_destroy(pthread_rwlock_t* rwlock);
int pthread_rwlock_rdlock(pthread_rwlock_t* rwlock);
int pthread_rwlock_tryrdlock(pthread_rwlock_t* rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t* rwlock);
int pthread_rwlock_trywrlock(pthread_rwlock_t* rwlock);
int pthread_rwlock_unlock(pthread_rwlock_t* rwlock);
这7个函数成功返回0,失败返回错误号。
这些函数的使用和互斥锁几乎相同,不再赘述。
读写锁适用于对数据读的次数远大于写的次数 的场景。
5.3 条件变量
条件变量并不是锁,但它能阻塞线程,直至接收到 “条件成立” 的信号后,被阻塞的线程才能继续执行。
5.3.1 条件变量的相关函数
#include<pthread.h>
int pthread_cond_init(pthread_cond_t* restrict cond, const pthread_cond_attr* restrict attr);
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int pthread_cond_destroy(pthread_cond_t* cond);
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 mutex,
const struct timespec* restrict abstime);
int pthread_cond_signal(pthread_cond_t* cond);
int pthread_cond_broadcast(pthread_cond_t* cond);
上述6个函数成功返回0,失败返回错误号。
条件变量的详细讲解,参考这篇博客,我愿称之为最强条件变量入门博客。
这里只说一下pthread_cond_wait
函数,理解该函数极其重要。wait
函数用于阻塞当前线程,“等待条件成立”。说是“等待条件成立”,但是线程调用了wait
只会阻塞,根本没法去检查条件,所以,等待条件成立其实是等待“条件成立”的信号,条件成立后会有人主动告诉他条件成立了。
而wait
函数也不止阻塞线程这一个动作:线程被阻塞后会立即解锁,这合起来是一个原子操作,不会被其他线程打断。
线程执行了wait
后的动作可理解为:即使线程拿到锁了,但是没有收到信号,线程就得等待,等待就还必须解锁,好让别人拿到锁。
当收到“条件成立”的信号后,wait
也不会立即解除阻塞,而是先重新上锁,再解除阻塞,这也是一个原子操作,不会被其他线程打断。
5.3.2 条件变量的生产者消费者模型
消费者: 调用wait
或timedwait
阻塞等待产品(“条件成立”的信号)
生产者: 一旦生产出了产品,就调用signal
或broadcast
通知消费者产品生产好了
其中还涉及到很多加锁解锁的细节。
6、进程间同步
6.1 信号量
上面互斥锁、读写锁以及条件变量都只能用于线程间同步,而信号量既可以用于线程间同步,又可用于进程间同步。
不难发现,与线程 以及线程同步 有关的所有函数,(若有返回值)成功都返回0,失败返回错误号。而下面即将介绍的信号量相关函数将回归正常,即,失败返回-1。
6.1.1 信号量的相关函数
#include<semaphore.h>
int sem_init(sem_t* sem, int pshared, unsigned int value);
int sem_destroy(sem_t* sem);
下面四个函数用于对信号量进行 -- 操作,信号量最小是0,若信号量 = 0,则调用wait会阻塞
int sem_wait(sem_t* sem);
int sem_trywait(sem_t* sem);
int sem_timedwait(sem_t* sem, const struct timespec* abs_timeout);
下面四个函数用于对信号量进行 ++ 操作
int sem_post(sem_t* sem);
上述6个函数成功返回0,失败返回-1并设置 errno。
sem_init 函数:
该函数用于初始化一个sem_t
类型变量,即信号量。第一个参数sem
是传出参数。
第二个参数表示这个信号量是在进程间共享还是线程间共享。若pthread
的值为0,那么该信号量将在线程间共享。若pthread
的值不为0,那么信号量在进程间共享,并且信号量应该位于共享内存(如mmap
建立的映射区)中的某个区域
不能初始化一个已经初始化的信号量。
sem_post 函数:
使信号量增加(解锁)。如果信号量的值因此大于0,那么阻塞在sem_wait
调用中的另一个进程或线程将被唤醒并继续锁定信号量。
6.1.2 信号量中的生产者消费者模型
需使用两个信号量,一个是空位数,一个是产品数。
生产者: 生产时需要判断空位数信号量是否为0(使用wait
函数),不为0才能生产,否则阻塞,成功生产后需要增加产品数信号量(使用post
函数)
消费者: 消费时需要判断产品数信号量是否为0(使用wait
函数),不为0才能消费,否则阻塞,成功消费后需要增加空位数信号量(使用post
函数)。
6.2 互斥锁
其实互斥锁不仅能用于线程间同步,还能用于进程间同步。这需要在pthread_mutex_init
初始化之前修改其属性为进程间共享。
6.2.1 修改mutex
属性为进程间共享
相关函数:
pthread_mutexattr_t attr; 声明 mutex 属性结构体
int pthread_mutexattr_init(pthread_mutexattr_t* attr); 初始化 mutex 属性
int pthread_mutexattr_destroy(pthread_mutexattr_t* attr); 销毁 mutex 属性
int pthread_mutexattr_setpshared(pthread_mutexattr_t* attr, int pshared);
pshared 的取值如下:
PTHREAD_PROCESS_PRIVATE 线程锁,mutex 的默认属性
PTHREAD_PROCESS_SHARED 进程锁
6.3 文件锁
7、子进程如何处理从父进程继承来的锁??
当父进程调用fork
创建子进程后:
① 即使父进程中有多个线程,子进程也只会继承调用fork
的那个线程的代码
② 子进程的地址空间是父进程地址空间的拷贝,所以会继承父进程中所有的互斥锁、读写锁 和 条件变量, 包括其状态 。
③ 锁的状态虽然会被继承,但子进程没有任何手段得知锁的状态到底是什么
④ 子进程继承到的锁是一个新锁,虽然有初始状态了,但是 和原来的锁没有任何瓜葛 ,即使父进程中,该锁解锁了,子进程中该锁不会有任何变化。
明确以上4点后,就能理解这个问题了。子进程使用从父进程继承而来的锁很容易导致死锁。 因为,从父进程继承而来的锁有可能是已经上锁了的(与父进程的锁毫无瓜葛,不可能被解锁的),如果子进程再调用lock
上锁,那就是一个线程上两次锁,造成死锁。
为了解决这种情况,pthread
专门提供了一个函数,pthread_atfork
,确保fork
调用后父进程和子进程都拥有一个清楚的锁状态,函数定义如下:
int pthread_atfork(void (*prepare)(void), void (*parent)(void), void (*child)(void));
8、线程和信号
Linux中的线程和信号,适配的不是很好,一般用的少,要用的时候去看《Linux高性能服务器编程》的第285页。
9、多线程编程的注意事项
① 在多线程中调用库函数时,一定要使用其可重入版本,否则会导致未定义的结果。(Linux的很多库函数都是不可重入的,但大多都提供了可重入版本,可重入版本在原函数名尾部加上 _r
)