1. 线程概念
2. 线程的操作
首先,要明确Linux是没有真线程的,它只有轻量级进程,所以在内核里它只有轻量级进程的系统调用,没有线程的系统调用。用户所看到的线程接口实际上是对轻量级进程的系统调用的封装。这些封装在原生线程库:pthread库,这也是linux系统必须自带的库,要编写多线程的程序,就一定要链接pthread库(l -pthread)。
2.1 线程的创建
功能:创建一个新的线程
原型:
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;失败返回错误码
pthread_t pthread_self(void);
2.2 线程的终止
功能:线程终止
原型:
void pthread_exit(void *value_ptr);
参数:
value_ptr:value_ptr不要指向一个局部变量。
返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)
功能:取消一个执行中的线程
原型:
int pthread_cancel(pthread_t thread);
参数:
thread:线程ID
返回值:成功返回0;失败返回错误码
2.3 线程的等待
功能:等待线程结束
原型:
int pthread_join(pthread_t thread, void **value_ptr);
参数:
thread:线程ID
value_ptr:它指向一个指针,后者指向线程的返回值
返回值:成功返回0;失败返回错误码
线程等待的必要性:
1.已经退出的线程,其空间没有被释放,仍然在进程的地址空间内
2.创建新的线程不会复用刚才退出线程的地址空间
2.4 线程的分离
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:
int pthread_detach(pthread_t thread);
int pthread_detach(pthread_self());
3. 线程与进程
线程间共享:代码和全局数据、进程的文件描述表、信号的处理方式、当前目录、用户ID和组ID。
线程私有:线程的硬件上下文(CPU寄存器的值)、独立的栈结构、线程ID、信号屏蔽字、调度优先级、errno。
4. 线程的优缺点
1. 优点:
1.创建一个新线程的代价要比创建一个新进程小得多
2.与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
3.线程占用的资源要比进程少很多
4.能充分利用多处理器的可并行数量
5.在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
6.计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
7.I/O密集型应用,为了提高性能,将I/O操作重叠,线程可以同时等待不同的I/O操作
2. 缺点:
1.性能损失:一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变
2.健壮性降低:编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的
3.缺乏访问控制:进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响
4.编程难度提高:因为健壮性低、缺乏访问控制
5. 线程的其他
5.1 线程的异常
5.2 线程的用途
5.3 线程的切换(了解)
与进程间的切换相比,线程间的切换需要操作系统做的工作要少很多。这是因为CPU在调度执行流的时候,如果每次都要重新从内存中读取数据到CPU是很浪费效率的,根据局部性原理,所以为了提高效率,从内存中读取的数据会保存到CPU中的Cache(高速缓冲存储器)。缓存的出现就可以极大提高线程切换的效率,与进程相比,线程所切换的资源很少。
5.3 pthread_t类型
6. 线程的互斥
6.1 互斥量
大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。多个线程并发的操作共享变量,会带来一些问题,如数据不一致问题。这些并发访问时出现的问题,多个执行流执行访问全局数据的代码导致的,所以要保护全局共享资源,也就是保护临界区。这时候就需要一把锁,也就是互斥量。加锁的本质就是把并发执行变成串行执行。
在讲互斥量前,要先知道下面的一些概念:
1.临界资源:多线程执行流共享的资源就叫做临界资源
2.临界区:每个线程内部,访问临界资源的代码,就叫做临界区
3.互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
4.原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
6.2 互斥量接口
1. 初始化:
静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
动态分配:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict
attr);
参数:
mutex:要初始化的互斥量
attr:NULL
2. 销毁:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
注意:
1. 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
2. 不要销毁一个已经加锁的互斥量
3. 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
3. 加锁与解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,
那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号
6.3 互斥原理
上面说到的交换,本质不是拷贝到寄存器,而是所有线程在争抢锁(1)的时候,只有一个数据1。CPU的寄存器硬件只有一套,但是寄存器内的数据,即线程的硬件上下文数据。如果数据在内存里,所有线程都能访问,属于共享。但是一旦转移到CPU寄存器里,就属于这个线程私有的了。
7. 可重入VS线程安全
7.1 线程安全
线程不安全 | 线程安全 |
不保护共享变量的函数 | 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限 |
函数状态随着被调用,状态发生变化的函数 | 类或者接口对于线程来说都是原子操作 |
返回指向静态变量指针的函数 | 多个线程之间的切换不会导致该接口的执行结果存在二义性 |
调用线程不安全函数的函数 |
7.2 可重入
可重入
| 不可重入 |
调用了
malloc / free
函数,因为
malloc
函数是用全局链表来管理堆的
|
不使用全局变量或静态变量
|
调用了标准
I/O
库函数,标准
I/O
库的很多实现都以不可重入的方式使用全局数据结构
|
不使用用
malloc
或者
new
开辟出的空间
|
可重入函数体内使用了静态的数据结构
|
不调用不可重入函数
|
不返回静态或全局数据,所有数据都有函数的调用者提供
| |
使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
|
7.3 可重入与线程安全的联系和区别
8. 线程的同步
竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。
8.1 条件变量
8.2 条件变量接口
全局静态:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict
attr);
参数:
cond:要初始化的条件变量
attr:给NULL
注意:
1. 全局的条件变量自己会初始化和销毁,局部的才要手动。
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_broadcast(pthread_cond_t *cond);
唤醒等待中的某个线程
int pthread_cond_signal(pthread_cond_t *cond);
9. 生产者消费者模型
9.1 概念
仓库是某种数据结构,是数据“交易”的场所。
该模型尽管有很好的并发度,但也存在着并发问题:
1. 生产者与生产者间
2. 生产者与消费者间
3. 消费者与消费者间
9.2 基于BlockingQueue的生产者消费者模型
核心代码:
// 生产者调用的接口
void Enqueue(T &in)
{
pthread_mutex_lock(&_mutex);
// bq满了就不能继续生产,需要等待,直到不满再获取锁
while (IsFull())
{
_producer_wait_num++;
// pthread_cond_wait调用:a.让当前线程在临界区等待 b.释放获得的锁 c.条件满足,线程被唤醒,重新竞争锁
// 上面的过程是线性的
pthread_cond_wait(&_product_cond, &_mutex);
_producer_wait_num--;
}
_blockqueue.push(in);
// 如果当前有消费者在等待,就唤醒消费者
// 不用每次都要唤醒,一旦没有消费者在等待,就是无效功
if (_consumer_wait_num > 0)
pthread_cond_signal(&_consum_cond);
pthread_mutex_unlock(&_mutex);
}
// 消费者调用的接口,输出型参数
void Pop(T *out)
{
pthread_mutex_lock(&_mutex);
// 如果用if判断条件,会出现伪唤醒
while (IsEmpty())
{
_consumer_wait_num++;
pthread_cond_wait(&_consum_cond, &_mutex);
_consumer_wait_num--;
}
*out = _blockqueue.front();
_blockqueue.pop();
if (_producer_wait_num > 0)
pthread_cond_signal(&_product_cond);//唤醒可以在解锁前,也可以在后
pthread_mutex_unlock(&_mutex);
}
10. POSIX信号量
10.1 信号量接口
1. 初始化信号量
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数:
pshared:0表示线程间共享,非零表示进程间共享
value:信号量初始值
2. 销毁信号量
int sem_destroy(sem_t *sem);
3. 等待信号量
功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t *sem);
P()操作的底层实现
4. 发布信号量
功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。
int sem_post(sem_t *sem);
V()操作的底层实现
10.2 基于环形队列的生产消费模型
环形队列采用数组模拟,用模运算来模拟环状特性:
核心代码:
void P(sem_t &sem)
{
sem_wait(&sem);
}
void V(sem_t &sem)
{
sem_post(&sem);
}
// 生产者关心空间
void Enqueue(const T &in)
{
// 因为P、V操作是原子的,可以放在加锁|解锁外面
// 理解为:先买票(P),再排队(竞争锁)
// 因为竞争时,每个线程已经分配好信号量了
// 效率比:先排队(竞争锁),再买票(P) 高
// 因为竞争时,只有竞争锁成功的线程才能继续分配信号量,期间其它线程只能等待
P(_room_sem);
Lock(_producer_mutex);
_ringqueue[_produceridx++] = in;
_produceridx %= _capacity;
Unlock(_producer_mutex);
V(_data_sem);
}
// 消费者关心数据
void Pop(T *out)
{
P(_data_sem);
Lock(_consumer_mutex);
*out = _ringqueue[_consumeridx++];
_consumeridx %= _capacity;
Unlock(_consumer_mutex);
V(_room_sem);
}
11. 常见锁概念
11.1 死锁
11.2 自旋锁
自旋锁:当获取锁失败时,线程不会进入阻塞状态,而会以自旋的方式反复尝试获取锁,直到获取成功或达到一定的尝试次数。
自旋锁的核心思想是快速尝试获取锁,避免线程进入阻塞状态,从而减少线程切换的开销。
不论是阻塞等待还是自旋,都是等待检测锁就绪的策略。
自旋锁的使用和普通锁的使用在表现上是类似的,它的调用接口模式参考锁的接口,以下是自旋锁的接口:
初始化
int pthread_spin_init(pthread_spinlock_t *lock,int pshared);
销毁
int pthread_spin_destory(pthread_spinlock_t *lock);
加锁
int pthread_spin_lock(pthread_spinlock_t *lock);
解锁
int pthread_spin_unlock(pthread_spinlock_t *lock);
在临界区中的线程执行时间长短和自旋锁的关系:
1. 如果时间长,推荐用普通锁,其他阻塞挂起等待
2. 如果时间短,推荐用自旋锁,其他线程不阻塞等待,而是一直抢占锁,直到成功