线程
线程是进程内部的一条执行序列或执行路径,一个进程可以包含多条线程。
- 从资源分配的角度来看,进程是操作系统进行资源分配的基本单位。
- 从资源调度的角度来看,线程是资源调度的最小单位,是程序执行的最小单位
一个进程可以并发执行多个线程,每个线程会执行不同的任务。对应在现实生活中,进程是组长,线程是小组成员。
语言级别的多线程的优势:可以跨平台运行
其原理就是:
语言层面上的thread
可以通过编译器的编译的宏信息识别操作系统,系统底层使用仍然是使用底层API如下:
- windows:createThread
- Linux:pthread_create
接下来的几部分我们主要介绍以下内容:
thread
-
mutex
(互斥锁):lock_guard
和unique_lock
-
condition_variable
(条件变量)
-
atomic
:基于CAS操作的原子类型,线程安全
sleep_for
创建启动一个线程
包含头文件:
#include<thread>
创建启动方式如下:
定义一个线程对象,传入线程所需要的线程函数和参数,线程自动开启。
简单示例:
void threadHandle1(int time)
{
// 让子线程睡眠time秒
std::this_thread::sleep_for(std::chrono::seconds(time));
cout << "hello thread1!" << endl;
}
int main()
{
//创建一个线程对象,传入一个线程函数,新线程就开始运行了
std::thread t1(threadHandle1,2);//参数还可以更多,传入什么样的实参就在线程函数定义即可
//主线程等待子线程结束(阻塞),主线程继续往下运行
t1.join();
cout<<"main thread done!"<<endl;
return 0;
}
注意
:由于是两个单独的线程,线程运行的先后顺序完全由CPU的调度算法决定,不能理解为main就会先于子线程执行。
问题
:主线程一定要等待子线程结束吗?
答:正常情况下,在多线程程序中,我们的主线程需要等待子线程运行完,即主线程运行结束后不能存在正在运行的未结束的其他的子线程
,否则整个进程就会异常终止。
在编写过程中,你也可以不等待,改为如下的书写方式:即把子线程设置为分离线程,检查时不关注该线程
。——也就是主线程和子线程断绝父子关系了。
int main()
{
std::thread t1(threadHandle1,2);
//t1.join();
t1.detch();
cout<<"main thread done!"<<endl;
return 0;
}
上面的子线程由于睡眠了两秒,还没有等到被执行完成,由于主线程结束,它也就自动结束了。
小总结
主线程如何处理子线程?
-
t.join
——等待t线程结束,当前线程继续往下运行;
-
t.detch
——不再理会子线程执行结束与否,主线程结束,进程结束,所有子线程都会自动结束
区分线程
线程除了站在我们角度上的以名字区分,它还有一个属于自己的id!
通过std::thread::get_id()
便可以获取到该成员对象线程的id。
std::cout << "t1 thread :: ID = " << t1.get_id() << std::endl;
而在线程函数中通过std::this_thread::get_id()
获取线程id。
std::cout << "inside thread :: ID = " << std::this_thread::get_id() << std::endl;
结束线程
线程结束主要分为以下四种方式:
- 线程函数返回(推荐)
- 调用
ExitThraed
函数,线程自行撤销
- 同一进程或者另一个进程中调用
TerminateThread
函数
-
ExitProcess
和TerminateProcess
函数也可以用来终止线程进行
步入多线程编程
总结了线程操作的一些基本知识之后,我们来使用一个小的情境看一下多线程编程究竟是怎样的?
模拟车站的三个买票窗口来卖100张票。
#include <iostream>
#include<thread>
#include<list>
using namespace std;
int ticketCount = 100; // 车站有100张车票,由三个窗口一起卖票
//模拟卖票的线程函数
void sellTicket(int index)
{
while(ticketCount > 0)
{
cout << "窗口:" << index << "卖出第" << ticketCount << "张票" << endl;
ticketCount--;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
int main()
{
list<std::thread> tlist;
for (int i = 1; i <= 3; ++i)
{
tlist.push_back(std::thread(sellTicket, i));
}
for (std::thread& t : tlist)
{
t.join();
}
cout << "所有窗口卖票结束!" << endl;
return 0;
}
仔细观察运行的程序,我们会发现同一个票数出现两次的情况,也就是一张票被卖了多次。
究其原因:我们也知道,因为这个操作不是原子性的,–操作做了三步:
mov eax, count
sub eax,1
mov count,eax
CPU可能刚执行完sub操作的时候,还没有把99写会到count中,该线程(t1)时间片到了执行到其他线程(t2),这样其他卖票窗口拿到的count也是100,然后这个线程(t2)执行完count = 99,CPU又回去执行t1,这时你就会白给一张票。
注意
:多线程程序中的名词
-
竞态条件
:多线程程序执行的结果是一致的,不会随着CPU对线程的不同调用顺序,而产生不同的结果。(一旦同一个程序在不同次的调动过程中,运行结果存在差异,就代表存在竞态条件)
为了解决上面的问题,我们引入了锁及锁操作
:
锁
在多线程程序中,需要满足竞态条件,我们就需要定义一把锁:
std::mutex mtx; // 全局的一把互斥锁
void sellTicket(int index)
{
while (ticketCount > 0) // ticketCount=1 锁+双重判断
{
mtx.lock();
if (ticketCount > 0)
{
cout << "窗口:" << index << "卖出第:" << ticketCount << "张票!" << endl;
//cout << ticketCount << endl;
ticketCount--;
}
mtx.unlock();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
这样的程序看似运行结果的票数没什么问题了
但是仔细分析,你会发现,这时只有一个窗口在卖票,为什么呢?
因为第一个线程获取到这一把互斥锁之后,其他线程是获取不到的,因为第一个线程还没有释放这把锁,其他线程就被阻塞了。此时第一个线程一直处在一个循环的状态,所以就是它把所有的票都卖光了。
于是我们修改代码:
int ticketCount = 100;
std::mutex mtx; // 全局的一把互斥锁
void sellTicket(int index)
{
while (ticketCount > 0) // ticketCount=1 锁+双重判断
{
mtx.lock();
//临界区代码段-需要保证原子操作-线程互斥操作-mutex
cout << "窗口:" << index << "卖出第:" << ticketCount << "张票!" << endl;
ticketCount--;
mtx.unlock();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
int main()
{
list<std::thread> tlist;
for (int i = 1; i <= 3; ++i)
{
tlist.push_back(std::thread(sellTicket, i));
}
for (std::thread& t : tlist)
{
t.join();
}
cout << "所有窗口卖票结束!" << endl;
return 0;
}
我们也就得到了思考;加锁操作一定要作用在临界区代码段上下,扩大范围就会出现错误。
好像解决了只有一个窗口在卖票的问题,但是,仔细思考,当剩下最后一张票时,一个线程进入临界区代码段,获取到一把互斥锁之后,正在对count进行–操作,但是此时还没有完成这个操作,此时第二个线程调用该函数,判断后也进入了循环,然后线程1完成–操作后,我们会发现,此时的线程2正在卖第0张票,因此这里也有bug,真的坑!!
于是我们在进入判断后又添加了条件:
即 锁+双重判断
while (ticketCount > 0) // ticketCount=1 锁+双重判断
{
mtx.lock();
if(ticketCount > 0)
{
cout << "窗口:" << index << "卖出第:" << ticketCount << "张票!" << endl;
ticketCount--;
}
mtx.unlock();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
这才完成一个窗口卖票的程序,没有什么大的错误。
继续研究:上述的临界代码段中一旦存在什么结束程序或者return的操作,那么我们 将无法执行到锁释放操作,导致进程出现死锁
问题,我们在这里借助智能指针
的思想,再次修改代码:
// 模拟卖票的线程函数 lock_guard unique_lock
void sellTicket(int index)
{
while (ticketCount > 0) // ticketCount=1 锁+双重判断
{
//控制一下作用域
{
// 保证所有线程都能释放锁,防止死锁问题的发生
lock_guard<std::mutex> lock(mtx); //相当于scoped_ptr,封装mutex,不支持移动拷贝构造和赋值重载
if (ticketCount > 0)
{
cout << "窗口:" << index << "卖出第:" << ticketCount << "张票!" << endl;
ticketCount--;
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
这里用了lock_guard<std::mutex> lock(mtx)
; 把锁包装成了一个类,保证能出函数一定会释放锁,避免死锁问题。
小总结
线程间的互斥 ——> 互斥锁mutex
——> lock_guard封装mutex
——> 防止死锁
线程间同步通信——生产者消费者模型
多线程编程中存在两个问题:
- 线程间的互斥:发生竞态条件的代码称作临界区代码段,我们每次进入临界区时,必须=保证原子操作,使用互斥锁mutex,或者轻量级的无锁机制CAS
- 线程间的同步通信:线程之间如果不通信,那么线程的调度顺序完全由调度算法决定,没有任何顺序可言,所以多线程程序出现问题时经常难以复现,因此需要保证线程之间通信来确定执行顺序。
首先,我们通过一个非常常用的模型:
生产者——消费者模型
来了解一下线程间通信
现在就用一个比较常用的模型来认识一下线程通信。
首先先定义一下互斥锁mtx
和条件变量cv
:
#include<condition_variable>//条件变量
#include<mutex>
std::mutex mtx; // 定义互斥锁,做线程间的互斥操作
std::condition_variable cv; // 定义条件变量,做线程间的同步通信操作
我们知道STL内的容器都不是线程安全的,所以不能够使用在多线程环境下,我们又不可能去容器内部修改其或是pop
或者push
操作的源代码,因此我们需要简单对其进行封装:
// 生产者生产一个物品,通知消费者消费一个;消费完了,消费者再通知生产者继续生产物品
class Queue
{
public:
void put(int val) // 生产物品
{
//lock_guard<std::mutex> guard(mtx); // scoped_ptr
unique_lock<std::mutex> lck(mtx); // unique_ptr
while (!que.empty())// que不为空,生产者应该通知消费者去消费,消费完了,再继续生产
{
// 生产者线程进入阻塞等待状态,并且把mtx互斥锁释放掉,让消费者抢到这把锁
cv.wait(lck); // lck.lock() lck.unlock
}
que.push(val);
/*
notify_one:通知另外的一个线程的
notify_all:通知其它所有线程的
通知其它所有的线程,我生产了一个物品,你们赶紧消费吧
其它线程得到该通知,就会从等待状态 =》 阻塞状态 =》 获取互斥锁才能继续执行
*/
cv.notify_all();
cout << "生产者 生产:" << val << "号物品" << endl;
}
int get() // 消费物品
{
//lock_guard<std::mutex> guard(mtx); // scoped_ptr
unique_lock<std::mutex> lck(mtx); // unique_ptr
while (que.empty())
{
// 消费者线程发现que是空的,通知生产者线程先生产物品
// #1 进入等待状态 # 把互斥锁mutex释放
cv.wait(lck);
}
int val = que.front();
que.pop();
cv.notify_all(); // 通知其它线程我消费完了,赶紧生产吧
cout << "消费者 消费:" << val << "号物品" << endl;
return val;
}
private:
queue<int> que;
};
定义生产者和消费者的线程函数:
void producer(Queue *que) // 生产者线程
{
for (int i = 1; i <= 10; ++i)
{
que->put(i);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
void consumer(Queue *que) // 消费者线程
{
for (int i = 1; i <= 10; ++i)
{
que->get();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
创建两个线程:
int main()
{
Queue que; // 两个线程共享的队列
std::thread t1(producer, &que);
std::thread t2(consumer, &que);
t1.join();
t2.join();
return 0;
}
执行逻辑图示:好好看!
总结:再谈lock_guard和unique_lock以及condition_variable
-
mtx.lock()
和mtx.unlock()
的临界区之间可能会造成死锁问题,于是我们采用lock_guard
,类似于智能指针的做法来避免死锁。
-
lock_guard
类似于scoped_ptr
,其源代码中,将左值引用的拷贝构造和赋值删除,所以简单的使用场景下(临界区代码段的加锁解锁)可以使用,但是在函数参数传递或返回过程中不能使用。
-
unique_lock
它不仅可以使用在简单的临界区代码段的互斥操作中,还能用在函数调用过程中(删除了左值引用的拷贝构造和赋值,但提供了右值的拷贝构造和赋值)。
- 条件变量(条件锁)也可以解决线程同步和共享资源访问的问题,条件变量是对互斥锁的补充,它允许一个线程阻塞并等待另一个线程发送的信号,当收到信号时,阻塞的线程被唤醒并试图锁定与之相关的互斥锁。
-
cv.wait(lck)
①使线程进入阻塞状态,②lck.unlock
可以把mtx释放掉
-
cv.notify_all
通知在cv上等待的线程,条件成立了,起来干活;其他cv上等待的线程,收到通知,从等待状态,变成阻塞状态,直到其他线程获取到互斥锁,其他线程继续执行。
CAS原子操作
因为互斥锁的操作是比较重,如果在临界区代码做的事情比较复杂,比较多。我们确实应该采用互斥锁。但对于类似于卖票系统的++--
操作,我们没必要用互斥锁,于是引入了CAS
来保证上面的++--
操作的原子特性,同时这也是无锁操作。
需要的头文件:
#include<atomic>
volatile std::atomic_bool isReady = false;
volatile std::atomic_int mycount = 0;
其实如果不用volatile
修饰,上面两个变量处在数据段,不同的线程栈不同,但是堆和数据段都是共享的,对于共享的变量,多线程是会进行缓存的,某个线程修改后,其他线程不能保证马上看到修改为true,因为读的都是缓存,volatile
防止了多线程对对共享变量进行缓存,保证了每次数据都是从原始内存拿,而不是有一定安全性风险的寄存器。
void task()
{
while (!isReady)
{
std::this_thread::yield(); // 线程出让当前的CPU时间片,等待下一次调度
}
for (int i = 0; i < 100; ++i)
{
mycount++;
}
}
int main()
{
list<std::thread> tlist;
for (int i = 0; i < 10; ++i)
{
tlist.push_back(std::thread(task));
}
std::this_thread::sleep_for(std::chrono::seconds(3));
isReady = true;
for (std::thread &t : tlist)
{
t.join();
}
cout << "mycount:" << mycount << endl;
return 0;
}