目录
Linux线程概念
- 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”
- 一切进程至少都有一个执行线程
- 线程在进程内部运行,本质是在进程地址空间运行
- 在Linux系统中,在CPU严重,看到的PCB都要比传统的进程更加轻量化
- 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流
线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠,线程可以同时等待不同的I/O操作。
线程的缺点
性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与其他线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
缺乏访问限制
线程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
编程难度提高
编写与调试一个多线程程序比单线程程序困难得多
线程异常
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有进程也就随即退出。
线程用途
- 合理的使用多线程,能提高CPU密集型程序的执行效率
- 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)
进程和线程
- 进程是资源分配的基本单位
- 线程是调度的基本单位
- 线程共享进程数据,但也拥有自己的一部分数据(线程ID,一组寄存器,栈,errno,信号屏蔽字,调度优先级)
进程的多个线程共享同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
- 文件描述符表
- 每种信号的处理方式(SIG_IGN,SIG_DFL或者自定义的信号处理函数)
- 当前工作目录
- 用户id和组id
进程控制
POSIX线程库
- 头文件: <pthread.h>
- 链接这些线程函数库时要使用编译器命令的"-lpthread"选项
创建进程
功能:创建一个新的线程
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void*), void *arg);
参数:
thread: 返回线程ID
attr: 设置线程的属性,attr为NULL表示使用默认属性
start_routine:是个函数地址,线程启动后要执行的函数
arg: 传给线程启动函数的参数
返回值:成功返回0,失败返回错误码
错误检查:
传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。
pthreads函数出错时不会设置全局变量errno(而大部分POSIX函数会这样做)。而是将错误代码通过返回值返回。
pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误,建议通过返回值判定,因为读取返回值要比读取线程内的errno变量的开销更小
#include <string.h>
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
void *rout(void *arg)
{
int i;
for (; ;)
{
printf("I'm thread 1\n");
sleep(1);
}
}
int main()
{
pthread_t tid;
int ret;
if ((ret = pthread_create(&tid, NULL, rout, NULL)) != 0)
{
fprintf(stderr, "pthread_create: %s\n", strerror(ret));
exit(EXIT_FAILURE);
}
int i;
for(; ;)
{
printf("I'm main thread\n");
sleep(1);
}
}
[ssj@VM-24-15-centos pthread]$ ./test1
I'm main thread
I'm thread 1
I'm main thread
I'm thread 1
I'm main thread
I'm thread 1
I'm main thread
I'm thread 1
I'm main thread
I'm thread 1
线程ID及线程地址空间布局
- pthread_create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是一回事。
- 前面讲的线程ID属于进程调度的范畴。线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一标识该进程。
- pthread_create函数的第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来进行的。
- 线程库NPTL提供了pthread_self函数,可以获得线程自身的ID:
pthread_t pthread_self(void);
pthread_t
到底是什么类型呢?取决于实现。对于Linux目前实现的NPTL而言,pthread_t类型的线程ID,本质就是进程地址空间上的一个地址。
线程终止
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
- 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
- 线程可以调用
pthread_exit
终止自己。 - 一个线程可以调用
pthread_cancel
终止同一进程中的另一个线程
pthread_exit函数
功能:终止线程
原型:
void pthread_exit(void *value_ptr);
参数:
value_ptr:不要指向一个局部变量。
返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者。(自身)
pthread_exit
或者return
返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其他线程得到这个返回指针时线程函数已经退出了。
pthread_cancel函数
功能:取消一个执行中的线程
原型:
int pthread_cancel(pthread_t thread);
参数
thread:线程ID
返回值:成功返回0;失败返回错误码
线程等待
- 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
- 创建新的线程不会复用刚才退出线程的地址空间
功能:等待线程结束
int pthread_join(pthread_t thread, void ** value_ptr);
参数
thread:线程ID
value_ptr:指向一个指针,后者指向线程的返回值
返回值:成功返回0;失败返回错误码
调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:
- 如果thread线程通过return返回,value_ptr所指向的单元里存放的是thread线程函数的返回值
- 如果thread线程被别的线程调用pthread_cancel异常终止,value_ptr所指向的单元里存放的是常数PTHREAD_CANCELED。
- 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
- 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ptr参数。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
void *thread1(void *arg)
{
printf("thread1 returning...\n");
int *p = (int*)malloc(sizeof(int));
*p = 1;
return (void*)p;
}
void *thread2(void *arg)
{
printf("thread2 existing...\n");
int *p = (int*)malloc(sizeof(int));
*p = 2;
pthread_exit((void*)p);
}
void *thread3(void *arg)
{
while(1)
{
printf("thread3 is running...\n");
sleep(1);
}
return NULL;
}
int main()
{
pthread_t tid;
void *ret;
//thread1 return
pthread_create(&tid, NULL, thread1, NULL);
pthread_join(tid, &ret);
printf("thread return, thread id %x, return code:%d\n", tid, *(int*)ret);
free(ret);
//thread2 exit
pthread_create(&tid, NULL, thread2, NULL);
pthread_join(tid, &ret);
printf("thread return, thread id %x, return code:%d\n", tid, *(int*)ret);
free(ret);
//thread3 cancel by other
pthread_create(&tid, NULL, thread3, NULL);
sleep(3);
pthread_cancel(tid);
pthread_join(tid, &ret);
if (ret == PTHREAD_CANCELED) printf("thread return, thread id %x, return code:PTHREAD_CANCELED\n", tid);
else printf("thread return, thread id %x, return code:NULL\n", tid);
}
分离线程
- 默认情况下,新创建的线程是joinable的,线程推出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
- 如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
int pthread_detach(pthread_t thread);
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:
pthread_detach(pthread_self());
joinable和分离是冲突的,一个线程不能既是joinable又是分离的。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <string.h>
#include <pthread.h>
void *thread_run(void *arg)
{
pthread_detach(pthread_self());
printf("%s\n", (char*)arg);
return NULL;
}
int main()
{
pthread_t tid;
if (pthread_create(&tid, NULL, thread_run, "thread1 run...") != 0)
{
printf("create, thread error\n");
return 1;
}
int ret = 0;
sleep(1); //很重要,要让线程先分离,再等待
if (pthread_join(tid, NULL) == 0)
{
printf("pthread wait success\n");
ret = 0;
}
else
{
printf("pthread wait failed\n");
ret = 1;
}
return ret;
}
线程互斥
相关概念
- 临界资源:多线程执行流共享的资源叫作临界资源。
- 临界区:每个线程内部,访问临界资源的代码,就叫作临界区。
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
- 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
互斥量mutex
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
- 多个线程并发的操作共享变量,会带来一些问题。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int tickets = 100;
void *route(void *arg)
{
char *id = (char*)arg;
while(1)
{
if (tickets > 0)
{
usleep(1000);
printf("%s sells ticket:%d\n", id, tickets);
tickets--;
}
else
{
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);
}
...
thread3 sells ticket:1
thread4 sells ticket:0
thread1 sells ticket:-1
thread2 sells ticket:-2
if
语句判断条件为真后,代码可以并发的切换到其他线程。usleep
这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入代码段--tickets
操作本身就不是一个原子操作
objdump -d test4 > test4.objdump
149 40065b: 8b 05 e3 09 20 00 mov 0x2009e3(%rip),%eax # 601044 <tickets>
150 400661: 83 e8 01 sub $0x1,%eax
151 400664: 89 05 da 09 20 00 mov %eax,0x2009da(%rip) # 601044 <tickets>
--
操作并不是原子操作,而是对应三条汇编指令:
load
:将共享变量tickets从内存加载到寄存器中update
:更新寄存器里面的值,执行-1操作store
:将新值,从寄存器写回共享变量tickets的内存地址
要解决以上问题,需要做到三点:- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他进程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就需要一把锁。Linux上提供的这把锁叫互斥量。
互斥量的接口
初始化互斥量
初始化互斥量的两种方法
- 方法一:静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
- 方法二:动态分配
int pthread_mutext_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
mutex: 要初始化的互斥量
attr: NULL
销毁互斥量
销毁互斥量需要注意:
- 使用
PTHREAD_MUTEX_INITIALIZER
初始化的互斥量不需要销毁 - 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号
调用pthread_lock
时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_mutex_lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
改进上面的售票系统:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int tickets = 100;
pthread_mutex_t mutex;
void *route(void *arg)
{
char *id = (char*)arg;
while(1)
{
pthread_mutex_lock(&mutex);
if (tickets > 0)
{
usleep(1000);
printf("%s sells ticket:%d\n", id, tickets);
tickets--;
pthread_mutex_unlock(&mutex);
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
}
}
int main()
{
pthread_t t1, t2, t3,t4;
pthread_mutex_init(&mutex, NULL);
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);
pthread_mutex_destroy(&mutex);
}
...
thread4 sells ticket:4
thread4 sells ticket:3
thread4 sells ticket:2
thread4 sells ticket:1
互斥量实现原理
- 我们已经直到单纯的
i++
或者++i
都不是原子的,有可能会有数据一致性问题 - 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的,总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。现在我们把lock和unlock的伪代码改一下:
lock:
movb $0, %al
xchgb %al, mutex
if(al寄存器的内容 > 0)
{
return 0;
}
else
{
挂起等待;
}
goto lock;
unlock:
movb $1, mutex
唤醒等待Mutex的线程;
return 0;
可重入vs线程安全
概念
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
常见的线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
- 可重入函数体内使用了静态的数据结构
常见可重入的情况
- 不使用全局变量或静态变量。
- 不使用malloc或者new开辟出的空间。
- 不调用不可重入函数。
- 不返回静态或全局数据,所有数据都有函数的调用者提供。
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
可重入与线程安全的联系
- 函数是可重入的,那就是线程安全的。
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全的区别
- 可重入函数是线程安全函数的一种。
- 线程安全不一定是可重入的,而可重入函数一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,就是不可重入的。
常见锁概念
死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用的不会释放的资源而处于的一种永久等待状态。
死锁的四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用。
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:一个执行流以获得的资源,在未使用完之前,不能强行剥夺。
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
线程同步
条件变量
- 当一个线程互斥地访问某个变量时,他可能发现在其它线程改变状态之前,它什么也做不了。
- 例如一个线程访问队列时,发现队列为空,它只能等待,直到其它线程将一个节点添加到队列中,这种情况就需要用到条件变量。
同步概念与竞态条件
- 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫作同步。
- 竞态条件:因为时序问题,而导致程序异常,称之为竞态条件。
条件变量函数
初始化
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
参数:
cond: 要初始化的条件变量
attr: NULL
销毁
int pthread_cond_destroy(pthread_cond_t *cond);
等待条件满足
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
cond: 要在这个条件变量上等待
mutex: 互斥量
唤醒等待
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
简单案例
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
pthread_cond_t cond;
pthread_mutex_t mtx;
void *r1(void *arg)
{
while (1)
{
pthread_cond_wait(&cond, &mtx);
printf("活动\n");
}
}
void *r2(void *arg)
{
while (1)
{
pthread_cond_signal(&cond);
sleep(1);
}
}
int main()
{
pthread_t t1, t2;
pthread_cond_init(&cond, NULL);
pthread_mutex_init(&mtx, NULL);
pthread_create(&t1, NULL, r1, NULL);
pthread_create(&t2, NULL, r2, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_mutex_destroy(&mtx);
pthread_cond_destroy(&cond);
}
[ssj@VM-24-15-centos pthread]$ ./mycond
活动
活动
活动
为什么pthread_cond_wait
需要互斥量?
- 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变的满足,并且通知等待在条件变量上的线程。
- 条件不会无缘无故的突然变得满足,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护,没有互斥锁就无法安全的获取和修改共享数据。
按照上面的说法,我们设计出如下的代码:先上锁,发现条件不满足,解锁,然后等待在条件变量上:
//错误的设计
pthread_mutex_lock(&mutex);
while (条件为假)
{
pthread_mutex_unlock(&mutex);
//解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过
pthread_cond_wait(&cond);
pthread_cond_wait(cond, mutex);
}
pthread_mutex_unlock(&mutex);
- 由于解锁和等待不是原子操作。调用解锁之后,
pthread_cond_wait
之前,如果已经有其他线程获取到互斥量,并且条件满足,发送了信号,可能会导致线程永远阻塞在这个pthread_cond_wait
。所以解锁和等待必须是一个原子操作。 int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
进入该函数后,会去看条件量是否等于0?等于,就把互斥量变成1,直到cond_wait返回,把条件量改为1,把互斥量恢复成原样。
条件变量使用规范
- 等待条件代码
pthread_mutex_lock(&mutex);
while (条件为假)
pthread_cond_wait(cond, mutex);
修改条件
pthread_mutex_unlock(&mutex);
- 给条件发送信号代码
pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);
POSIX信号量
POSIX信号量和SystemV信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。但POSIX可以用于线程间同步。
初始化信号量
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
pshared:0表示线程间共享,非零表示进程间共享
value:信号量初始值
销毁信号量
int sem_destroy(sem_t *sem);
等待信号量
功能:等待信号量,会将信号量的值-1
int sem_wait(sem_t *sem);
发布信号量
功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量+1。
int sem_post(sem_t *sem);
生产者-消费者模型
线程池
STL,智能指针的线程安全
STL中的容器是线程安全的吗?
不是。STL的设计初衷是将性能挖掘到极致,而一旦涉及到加锁保证线程安全,会对性能造成巨大的影响。而且对于不同容器,加锁的方式不同,性能可能也不同。(例如hash表的锁表和锁桶)。
因此,STL默认不是线程安全。如果需要在多线程环境下使用,往往需要调用者自行保证线程安全。
智能指针是线程安全的吗?
对于unique_ptr,由于只是在当前代码块范围内生效,因此不涉及线程安全问题。
对于shared_ptr,多个对象需要公用一个引用计数变量,所以会存在线程安全问题,但是标准库实现的时候考虑了这个问题,基于原子操作(CAS)的方式保证shared_ptr能够高效,原子的操作引用计数。
其他常见的锁
- 悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。
- 乐观锁:每次取数据时,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。
- CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。