一. 什么是线程
- 在一个程序里的一个执行路线叫做线程,线程是一个进程内部的控制序列。一个进程可以拥有多个线程,但是至少都有一个执行线程(单线程进程),线程的执行粒度比进程更细致,线程资源共享。
- 在Linux中并不存在真正的线程,Linux的线程是使用进程模拟的。我们在Linux系统中,线程的创建是在内核外进行的,有POSIX提供的线程库实现。因此链接这些线程函数库时要使用编译器命令的”-lpthread”选项。Linux下的线程也叫做轻量级进程(LWP)。
- 进程与线程的关系:进程是资源竞争的基本单位,线程是程序执行的最小单位,是承担调度的基本单位。
- 线程共享进程数据,但是也拥有自己独立的部分:线程ID,独立的上下文数据,栈,errno,信号屏蔽字,调度优先级
- 一个进程的多个线程之间可以共享
(1)同一地址空间,因此Text Segment,Data Segment都是共享的,如果定义一个函数或者全局变量,那么在各个线程中都可以访问到
(2)还共享文件描述符,每种信号的处理方式,当前工作目录,用户ID和组ID这样的进程资源和环境 - 线程的优点
(1)创建进程消耗的资源更少
(2)线程切换的开销更少
(3)充分利用多处理器的可并行数量
(4)线程占用的资源比进程少 - 线程的缺点
(1)如果计算密集型线程比可用处理器多,有可能增加了额外的同步和调度开销,而可用的资源不变。
(2)健壮性降低:编写多线程程序需要考虑的更加全面,有可能共享了不该共享的变量,造成不良影响
(3)缺乏访问控制:进程是访问控制的基本粒度,在一个线程中调用某些系统函数对整个进程造成影响
(4)编写程序难度较高
二. 线程控制
1. 创建线程
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
参数:
- thread返回线程ID;
- attr设置线程属性,默认为NULL;
- start_routine一个函数地址,线程启动后执行的函数;
- arg传给执行的函数的参数
返回值:成功返回0,失败返回错误码。
2. 进程ID和线程ID
在Linux中,目前的线程是靠POSIX线程库和进程实现的,在这种实现下,线程又被称为轻量级进程,每一个用户态的线程,在内核中都有一个对应的调度实体,也有自己的task_struct结构体。
在没有线程之前,一个进程对应内核里的一个task_struct,对应一个进程ID;但是在引入线程之后,一个进程下有n个用户态线程,每个线程作为独立的调度实体有自己的进程描述符,这样,进程和内核中的进程描述符变成了1:n关系,但是POSIX标准要求所有的线程调用getpid时返回相同的进程ID。此时,就有了线程组的概念。
struct task_struct {
...
pid_t pid;
pid_t tgid;
...
struct task_struct *group_leader;
...
struct list_head thread_group;
...
};
多线程的进程,又被称为线程组,线程组内的每一个线程在内核中存在一个进程描述符与之对应。其实在进程描述符中pid,描述的线程ID,其中的tgid对应的才是用户层的进程ID。
用户态 | 系统调用 | 内核进程描述符中对应的结构 |
---|---|---|
进程ID | pid_t getpid(void) | pid_t tgid(Thread Group ID) |
线程ID | pid_t gettid(void) | pid_t pid |
到此,我们知道线程ID跟进程ID一样,是pid_t类型的变量,那么如何查呢?
LWP:线程ID,即gettid()系统调用的返回值
NLWP:线程组内线程的个数
在Linux提供了gettid系统调用返回其线程ID,但是glibc并没有将该系统调用封装起来,并开放接口供我们使用;因此如果要获得线程ID,可以采用如下办法:
#include<sys/syscall.h>
pid_t tid;
tid = syscall(SYS_gettid);
3. 线程ID及进程地址空间布局
在使用pthread_create函数创建线程的时候,也会返回一个线程ID,这个线程ID和我们上面说的并不是一个东西。上面我们讲的是进程调度的范畴,pthread_create函数产生的线程是线程库的范围,后续的相关操作,根据的是这个线程ID。可以通过调用pthread_self()获得线程ID。
pthread_t pthread_self();
//pthread_t类型的线程ID,本质上是一个进程地址空间上的一个地址。
4. 进程终止
有三个方法可以终止线程:
(1)从线程函数return,但是对于主线程,从main函数return相当于调用exit
(2)线程可以通过pthread_exit终止自己
void pthread_exit(void *value_ptr);
(3)一个线程可以通过pthread_cancel终止同一进程中的另一个线程
int pthread_cancel(pthread_t thread);
三. 线程等待与分离
1. 线程等待
已经退出的线程,其空间没有被释放,仍然在进程的地址空间内,必须有线程等待回收它,否则会造成内存泄漏。创建的新线程不会服用刚才退出线程的地址空间。
函数原型:
int pthread_join(phread_t thread, void **value_prt);
参数: thread线程ID,value_ptr它指向一个指针,后者指向线程的返回值
返回值:成功返回0,失败返回错误码
- 如果线程通过return返回,value_ptr所指向的单元里存放thread线程函数的返回值
- 如果被别的线程通过pthread_cancel异常终止掉,value_ptr所指向的单元里存放常数PTHREAD_CANAELED
- 通过pthread_exit函数终止,value_ptr指向的单元存放的是传给pthread_exit的参数
- 不关心线程返回值,可以传NULL
2. 分离线程
默认的线程是joinable的,我们知道线程退出后,需要对其进行pthread_join操作,但是如果不关心线程的返回值,join就是一种负担,此时,我们可以让系统在线程退出后,自动释放线程资源。
int pthread_detach(pthread_t thread);
可以自己分离,也可以是对其他目标线程分离。需要注意的是,一个线程不能既是分离的又是joinable。
四. 线程同步与互斥
mutex互斥量
很多变量需要在线程间共享,这样的变量称为共享变量,通过数据的共享,完成线程之间的交互。但是此时会存在问题。
举一个例子:假如模拟两个线程去买火车票,可以发现剩下的票出现了负数的结果。
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<string.h>
int ticket = 100;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void* route(void* arg)
{
char* id = (char*)arg;
while(1)
{
//pthread_mutex_lock(&lock);
if(ticket>0)
{
usleep(1000);
printf("%s sells ticket: %d\n", arg, ticket);
ticket--;
//pthread_mutex_unlock(&lock);
}
else
{
//pthread_mutex_unlock(&lock);
break;
}
}
}
int main()
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, "thread1");
pthread_create(&t2, NULL, route, "thread2");
pthread_create(&t3, NULL, route, "thread3");
pthread_create(&t4, NULL, route, "thread4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
return 0;
}
运行结果:
为什么会这样?
- if判断后,代码可能并发的切换到其他线程
- 如果在买了票之后,票的总数减少一之前,可能会有其他进程进入该代码段
- 票数减少这个操作本身并不是一个原子操作。
因此,我们需要做到:
- 代码要有互斥行为,代码进入临界区时,其他线程不能进入该段代码
- 多个线程同时要求执行临界区的代码,并且临界区没有线程执行,只允许一个线程进入该临界区
- 如果线程不在临界区,不能阻止其他线程进入临界区
要达到以上要求,需要一把锁。下面介绍一下互斥量的使用
1. 初始化
(1)静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
(2)动态分配
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr); //mutex要初始化的互斥量,attr:NULL
2. 销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);
需要注意:
- 不要销毁一个已经加锁的互斥量;
- 静态初始化的互斥量不需要销毁;
- 已经销毁的互斥量,确保不会再有线程加锁
3. 加锁解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
条件变量
条件变量是实现线程同步的
1. 初始化
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *rest attr);
cond:要初始化的条件变量
attr:NULL
2. 销毁
int pthread_cond_destroy(pthread_cond_t *cond);
3. 等待条件满足
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
cond:要在这个条件变量上等待
mutex:互斥量
4. 唤醒等待
int pthread_cond_signal(pthread_cond_t *cond);//唤醒一个
int pthread_cond_broadcast(pthread_cond_t *cond);//全部唤醒
通常的情况是这样的:先上锁,发现条件不满足,解锁,然后等待条件变量满足,再加锁,完成临界区的操作后,最后在解锁。我们的pthread_cond_wait函数中,解锁和等待是一个原子操作。
条件变量使用规范
1. 等待条件
pthread_mutex_lock(&mutex);
while (条件为假)
pthread_cond_wait(cond, mutex);
//修改条件为真
pthread_mutex_unlock(&mutex);
2. 给条件发送信号
pthread_mutex_lock(&mutex);
//设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);
在实际执行过程中,往往是执行快的等待执行较慢的。