一、线程概念
(一)什么是线程?
- 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”。
- 在Linux系统中,Linux中,没有真正意义上的线程,线程是用进程模拟的,在CPU眼中,看到的PCB都要比传统的进程更加轻量化。
- 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。
- 线程:调度的基本单位,线程是进程里面的执行流,并且线程在进程的地址空间内运行。进程:线程 1 : n
(二)创建一个线程系统需要做什么?
进程是承担和分配系统资源的事情,也就是线程所应该具备的资源我们已经有了,所以创建线程不需要在额外申请和分配资源了 。
1.在内核层面
- 创建该线程的PCB, 与创建该线程的进程共享地址空间,
- 分配该进程的代码、数据资源。
2.在库层面
- 库为该线程形成线程相关的描述结构体,为线程形成私有栈,最后返回线程Id,本质就是线程私有信息的起始地址
3.结论
进程是承担系统分配资源的一个实体,而线程是调度的基本单位。
(三)线程的优缺点、用途及异常
1.线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多,且线程占用的资源要比进程少很多。
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。
- 能充分利用多处理器的可并行数量。
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务。
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
2.线程的缺点
- 性能损失
- 一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
- 健壮性降低
- 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
- 缺乏访问控制
- 进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
- 编程难度提高
- 编写与调试一个多线程程序比单线程程序困难得多。
3.线程用途
- 合理的使用多线程,能提高CPU密集型程序的执行效率
- 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)
4.线程异常
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。
(四)与进程相比
进程线程关系图
1.创建一个进程系统和内核做了什么也就是fork之后为我们做了什么?父进程调用了fork,创建了一个子进程,相当于系统多了一个子进程,也就是多了一批数据结构,一批代码数据和一批页表。
- 创建与该进程相关的数据结构,如PCB、地址空间、文件信号相关的数据结构。
- 为该进程开辟地址空间之后,将虚拟地址和物理地址直接通过页表进行映射。
- 在映射期间为物理内存开辟空间将该进程代码和数据加载至内存的合理地方。
- 此时创建的进程是一个独立个体,我们需要将该进程投递到等待或者运行队列当*cpu去调度。
线程共享以下进程资源和环境:
文件描述符表
每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
当前工作目录
用户id和组id
2.进程的多个线程共享同一地址空间以及Text Segment、Data Segment都是共享的,线程共享进程数据,但也拥有自己的一部分数据:
- 线程ID
- 一组寄存器
- 栈
- errno
- 信号屏蔽字
- 调度优先级
二、线程使用及线程安全
- 要使用线程,就需要POSIX线程库,头文件是<pthread.h>
- 链接这些线程函数库时要使用编译器命令的“-lpthread”选项
- 创建一个线程首先要使用pthread_t创建一个变量,所以pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。
(一)线程使用
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;失败返回错误码
2.终止一个线程
- 功能:
线程终止
- 原型:
void pthread_exit(void *value_ptr);
- 参数:
value_ptr:value_ptr不要指向一个局部变量。
- 返回值:
无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)
- 需要注意,pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。
3.取消线程
- 功能:取消一个执行中的线程
- 原型
- int pthread_cancel(pthread_t thread);
- 参数
- thread:线程ID
- 返回值:成功返回0;失败返回错误码
4.线程等待
- 功能:
等待线程结束
- 原型
int pthread_join(pthread_t thread, void **value_ptr);
- 参数
thread:线程ID
value_ptr:它指向一个指针,后者指向线程的返回值
- 返回值:
成功返回0;失败返回错误码
- 为什么需要线程等待?
对于已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。创建新的线程不会复用刚才退出线程的地址空间。调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:
1. 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
2. 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数PTHREAD_ CANCELED。
3. 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
4. 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。
5. 分离线程
当线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:
pthread_detach(pthread_self());int pthread_detach(pthread_t thread);
(二)线程互斥
当多个线程对同一份资源(临界资源)进行操作时,就会造成数据二义性,为了避免这种情况产生,引入互斥锁概念,本质上就是一把锁,Linux上提供的这把锁叫互斥量,使用互斥量,从而达到进程间的互斥,使对数据的操作具有原子性。
1.理解互斥量的概念
由上图可知,加入互斥锁后,在同一时刻,当第一个创建出来的线程进入临界区后,就会加锁,然后对临界区进行操作,当操作完后,就会解锁从而完成此次访问,这样就能保证在同一时刻该线程对数据的操作具有原子性。
2.互斥量原理
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
3.初始化互斥量
- 初始化互斥量有两种方法:
- 方法1,静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
- 方法2,动态分配:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrictattr);
- 参数:
mutex:要初始化的互斥量
attr:NULL
4.销毁互斥量
- int pthread_mutex_destroy(pthread_mutex_t *mutex);
- 销毁互斥量需要注意:
- 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
- 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
5.互斥量加锁和解锁
- int pthread_mutex_lock(pthread_mutex_t *mutex);
- int pthread_mutex_unlock(pthread_mutex_t *mutex);
- 返回值:成功返回0,失败返回错误号
- 调用 pthread_ lock 时,可能会遇到以下情况:
互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,
那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
6.死锁
死锁四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用。
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺。
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。
- 避免死锁
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
- 避免死锁算法
- 死锁检测算法:检测与解除死锁,分配资源时不采取措施,但是必须提供死锁的检测与解除手段。
- 银行家算法:即为避免策略,分配资源前进行风险判断,避免风险的发生,银行家算法是最有代表性的死锁避免而并非解除算法。
- 预防策略:破坏死锁产生的必要条件。
6.利用互斥原理实现一个模拟抢票过程
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
int ticket=100;
pthread_mutex_t lock;
int get_ticket(void*arg)
{
int num=(int)arg;
while(1)
{
usleep(1000);
pthread_mutex_lock(&lock);
if(ticket>0)
{
usleep(1000);
printf("thread %d,get a ticket,no: %d\n",num,ticket);
ticket--;
pthread_mutex_unlock(&lock);
}
else
{
pthread_mutex_unlock(&lock);
break;
}
}
}
int main()
{
int i=0;
pthread_t tid[4];
pthread_mutex_init(&lock,NULL);
for(;i<4;i++)
{
pthread_create(tid+1,NULL,get_ticket,(void*)i);
}
for(i=0;i<4;i++)
{
pthread_join(tid[i],NULL);
}
pthread_mutex_destroy(&lock);
return 0;
}
(三)线程同步
1.同步概念与竞态条件
- 同步:线程间对数据资源进行获取,有可能在不满足访问资源条件的情况下访问资源而造成程序逻辑混乱,因此通过进行条件判断来决定线程在不能访问资源时休眠等待或满足资源后唤醒等待的线程的方式实现对资源访问的合理性。
- 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解。
2.条件变量
2.1 概念理解:
当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情
况就需要用到条件变量。
2.2初始化函数:
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrictattr);
参数:
cond:要初始化的条件变量
attr:NULL
2.3销毁函数int pthread_cond_destroy(pthread_cond_t *cond)
2.4等待条件满足int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
- 参数:
cond:要在这个条件变量上等待
mutex:互斥量,后面详细解释
2.5唤醒等待
int pthread_cond_broadcast(pthread_cond_t *cond); //将整个队列中所有线程全部唤醒
int pthread_cond_signal(pthread_cond_t *cond); //唤醒队列队头的线程
2.6为什么 pthread_cond_wait 需要互斥量?
- 当线程A需要访问B中的资源Q时,A肯定需要拿到Q资源中的锁才可以进去访问,所以也就需要互斥量。
(四)线程安全与可重入
1.概念
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
2.线程安全与不安全情况
- 安全情况:每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的类或者接口对于线程来说都是原子操作,多个线程之间的切换不会导致该接口的执行结果存在二义性。
- 不安全情况:不保护共享变量的函数,函数状态随着被调用,状态发生变化的函数,返回指向静态变量指针的函数,调用线程不安全函数的函数。
3.可重入和不可重入情况
- 可重入情况
- 不使用全局变量或静态变量。
- 不使用用malloc或者new开辟出的空间。
- 不调用不可重入函数。
- 不返回静态或全局数据,所有数据都有函数的调用者提供。
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
- 不可重入情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
- 可重入函数体内使用了静态的数据结构。
4.可重入与线程安全区别与联系
- 区别
- 可重入函数是线程安全函数的一种。
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生
- 死锁,因此是不可重入的。
- 联系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
三、生产者消费者模型
(一)概念
- 生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。
- 321原则:该模型有两个角色:生产者、消费者,存在3个关系:生产者和消费者之间的同步关系,生产者和生产者之间的互斥关系、消费者和消费者之间的互斥关系。
- 这个模型优点是利于解耦,支持并发,解决忙闲不均。
(二)基于BlockingQueue的生产者消费者模型
- 在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)。
- 基于阻塞队列的生产消费模型代码实现
//mian.cc
#include"blockqueue.hpp"
using namespace std;
void *consumer_run(void *arg)
{
BlockQueue *bq=(BlockQueue*)arg;
while(1)
{
Task t;
bq->Get(t);
cout<<"consumer: "<<t.x<<"*"<<t.y<<"="<<t.Run()<<endl;
}
}
void *productor_run(void *arg)
{
sleep(1);
BlockQueue *bq=(BlockQueue*)arg;
while(1)
{
int x=rand()%10+1;
int y=rand()%100+1;
Task t(x,y);
bq->Put(t);
cout<<"productor Task is:"<<x<<"*"<<y<<"=?"<<endl;
sleep(1);
}
}
int main()
{
BlockQueue *bq=new BlockQueue(5);
pthread_t c,p;
pthread_create(&c,nullptr,consumer_run,(void*)bq);
pthread_create(&p,nullptr,productor_run,(void*)bq);
pthread_join(c,nullptr);
pthread_join(p,nullptr);
delete bq;
return 0;
}
//blockqueue.hpp
#pragma once
#include<iostream>
#include<queue>
#include<unistd.h>
#include<pthread.h>
class Task
{
public:
int x;
int y;
public:
Task()
{}
Task(int _x,int _y)
:x(_x),y(_y)
{}
int Run()
{
return x*y;
}
~Task()
{}
};
class BlockQueue
{
private:
std::queue<Task> q;
size_t cap;
pthread_mutex_t lock;
pthread_cond_t c_cond;//消费者等待的条件变量
pthread_cond_t p_cond;//生产者等待的条件变量
public:
bool IsFull()
{
return q.size()>=cap;
}
bool IsEmpty()
{
return q.empty();
}
void LockQueue()
{
pthread_mutex_lock(&lock);
}
void UnLockQueue()
{
pthread_mutex_unlock(&lock);
}
void WakeUpComsumer()
{
std::cout<<"wake up consumer..."<<std::endl;
pthread_cond_signal(&c_cond);
}
void WakeUpProductor()
{
std::cout<<"wake up productor..."<<std::endl;
pthread_cond_signal(&p_cond);
}
void ComsumerWait()
{
std::cout<<"consumer wait..."<<std::endl;
pthread_cond_wait(&c_cond,&lock);
}
void ProductWait()
{
std::cout<<"product wait.."<<std::endl;
pthread_cond_wait(&p_cond,&lock);
}
public:
BlockQueue(size_t _cap)
:cap(_cap)
{
pthread_mutex_init(&lock,nullptr);
pthread_cond_init(&c_cond,nullptr);
pthread_cond_init(&p_cond,nullptr);
}
void Put(Task t)
{
LockQueue();
while(IsFull())
{
WakeUpComsumer();
ProductWait();
}
q.push(t);
UnLockQueue();
}
void Get(Task &t)
{
LockQueue();
while(IsEmpty())
{
WakeUpProductor();
ComsumerWait();
}
t=q.front();
q.pop();
UnLockQueue();
}
~BlockQueue()
{
pthread_mutex_destroy(&lock);
pthread_cond_destroy(&c_cond);
pthread_cond_destroy(&p_cond);
}
};
(三)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); //P()
- 发布信号量
功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。
int sem_post(sem_t *sem);//V()
2.基于环形队列的生产消费模型
- 环形队列采用数组模拟,用模运算来模拟环状特性。
- 环形结构起始状态和结束状态都是一样的,不好判断为空或者为满,所以可以通过加计数器或者标记位来。
- 判断满或者空。另外也可以预留一个空的位置,作为满的状态。
- 但是我们现在有信号量这个计数器,就很简单的进行多线程间的同步过程。
3.代码实现
//main.cc
#include "RingQueue.hpp"
void *comsumer(void *ring_queue)
{
RingQueue *rq = (RingQueue*)ring_queue;
while(1)
{
int data = 0;
rq->Get(data);
std::cout << "comsumer done ...# " << data <<std::endl;
}
}
void *productor(void *ring_queue)
{
RingQueue *rq = (RingQueue*)ring_queue;
int count = 100;
while(1)
{
sleep(1);
rq->Put(count);
count++;
if(count > 110)
{
count=100;
}
std::cout << "prudoctor done ..." << std::endl;
}
}
int main()
{
pthread_t c,p;
RingQueue *rq = new RingQueue();
pthread_create(&c, nullptr, comsumer, rq);
pthread_create(&p, nullptr, productor,rq);
pthread_join(c, nullptr);
pthread_join(p, nullptr);
delete rq;
return 0;
}
//ringqueue
#pragma once
#include <iostream>
#include <vector>
#include <unistd.h>
#include <semaphore.h>
#define NUM 10
class RingQueue
{
private:
std::vector<int> v;
int max_cap;
sem_t sem_blank; //生产者
sem_t sem_data; //消费者
int c_index;
int p_index;
private:
void P(sem_t &s)
{
sem_wait(&s);
}
void V(sem_t &s)
{
sem_post(&s);
}
public:
RingQueue(int _cap=NUM)
:max_cap(_cap)
,v(_cap)
{
sem_init(&sem_blank, 0, max_cap);
sem_init(&sem_data, 0, 0);
c_index = 0;
p_index = 0;
}
void Get(int &out)
{
P(sem_data);
out = v[c_index];
c_index++;
c_index %= max_cap;
V(sem_blank);
}
void Put(const int &in)
{
P(sem_blank);
v[p_index] = in;
p_index++;
p_index %= max_cap;
V(sem_data);
}
~RingQueue()
{
sem_destroy(&sem_blank);
sem_destroy(&sem_data);
c_index = 0;
p_index = 0;
}
};
四、读者写者问题
日常应用场景:黑板报、新闻、路由表等。
1.有一批数据读的人特别多,但是修改的少。
2.同样遵守321原则三种关系主要是写者与写者互斥关系,写者与读者互斥且同步。读者与读者共享关系,不存在互斥关系也是与生产者消费者模型唯一区别之处。
3.生产者消费vs读者写者问题什么样原因产生这个区别:消费者会取走数据,读者不会!
4.读写锁接口 pthread_rwlock_init 初始化 pthread_rwlock_unlock 释放锁 pthread_rwlock_rdlock 读者锁 pthread_rwlock_wrlock 写者锁
5.读者写者同步问题:
读优先:只要能读的立马先读, ,但是当读者太多时,可能导致写不进去,会写饥饿。
写有先:反之
公平占有锁:谁先被调度,即谁占到锁先给谁。
6.该问题常见于读多写少,但是不仅限于此。
五、自旋锁
1.pthread_spinlock_init
2.没有被挂起放到等待队列中,一直不停在检测该临界资源是否加锁。
3.因为占有资源的线程,在临界区内呆的时间较短,无需挂起,让当前线程处于自旋状态,不断去检测锁的状态,而其中,自旋锁为我们提供上述功能!
4.spin本质如何实现?
给前面加上while(1)
5.用自旋锁还是普通锁取决于在临界资源内呆的时间长短。
6.锁在linux中,用户实现一部分,内核实现一部分。
7.自旋锁和被挂起 在用户角度看到的都是一样的,但是在内核中,自旋锁相当于不断在检测 ,都是被阻塞的。