多线程
多线程基本概念介绍
注意:多线程的东西其实有很多,这门课只讲常用的部分,把这些学会基本就够用了。
多线程的重要性:
(1) 对于一个专业的C++开发来说,多线程是必须掌握的模块。 (2) 现代程序都是多线程程序了。因为单核处理器的性能早已经达到了瓶颈,只能往多核方向发展。现代的个人计算机都是4核起步,工作站,服务器就更不用说了。 (3) 工作站可以理解为处理能力更强的大型个人计算机,常见的12核,16核。服务器有48核的,甚至更多。 (4) 对于一个计算机来说,是不是说核越多好呢?不是,多核会导致单核的工作性能下降。当核数多到一定程度后,反而总体运行效率下降了。不过,这并不影响现代计算机核数越来越多的趋势。 (5) 传统的单线程程序同时只能在一个核上运行,这是不是太浪费资源了。计算机有8个核,你就用了一个,暴殄天物啊。多线程程序可以使用多个核,极大提高程序运行效率。现在网络通信,音频,视频,游戏服务都是多线程程序。
并发与并行的概念介绍:
(1) 一句话来说:并行是同时在不同的处理器上处理不同的任务,并发是同时在一个处理器上处理多个任务。 解释一下: ① 并行是指有多个处理器。每个处理器各执行一个线程,互不抢占cpu资源,如果线程数量多于CPU,也没有办法,只能将处理器的时间划分为多个时间段,再将时间段分配给各个线程。 ② 并发是指只有一个处理器,但多个线程被轮换快速执行,使得宏观上有了同时执行的效果。作用原理是将单处理器的时间划分为多个时间段,再分配给不同的线程。同一时间段只能有一个线程在运行,其它线程均处于挂起状态。
进程的概念:
(1) 进程的概念在面向进程设计的操作系统(就是unix,也包括后面衍生出的linux,mac)和面向线程设计的操作系统(说的就是windows)上有很大区别,两种设计方式的共同点与不同点还是需要理解的。
① 进程是计算机中的程序对某些数据集合的一次运行活动,是系统进行资源分配和调度的最基本单位,是操作系统的结构基础。再用大白话说一遍,一个可执行程序执行起来,就是一个进程。当然,一个程序要执行起来需要各种资源,这些资源就是数据集合。
② 在面向进程设计的计算机结构中,进程是程序的基本执行单位,进程包括程序执行的所有资源,同时自己也可以执行。
③ 在面向线程设计的计算机结构中,线程才是程序的基本执行单位,进程不过是线程的容器罢了。进程就像一个仓库,里面存放了程序的所有资源,进程中的线程才是真正执行程序的单元。
线程的概念
(1) linux的线程和windows的线程还是有很大区别的。 (2) linux的线程就是一种轻量级的进程,只有依靠进程才可以存在。也模拟出了windows线程的方式,让线程成为真正的执行单元。 (3) windows的线程就简单多了,真正执行程序的最小单元。
总结:
说了这么多:其实对进程,线程只是个介绍,这里面水很深。而且windows多线程和linux多线程的区别并不影响我们学习C++11的多线程,C++标准任何平台通用。 现代C++程序,C++11的多线程功能才是主流,C++11的多线程就是windows模式的,进程为一个仓库,线程才是程序执行的最小单元。linux同样完美支持了这些功能。
线程的创建
主线程介绍:
一个程序执行起来就是一个进程。而main函数就是主线程,一旦主线程执行完毕,主线程结束,整个进程就会结束。
子线程介绍:
在一个线程执行时,我们可以创建出另外一个线程。两个线程各自执行,互补干涉。注意,当主线程执行完毕,就会强制结束所有子线程,然后进程结束,从这个角度来说,可以认为子线程是主线程的辅助线程。但是要明白主线程和子线程是平级的,只不过主线程执行完毕后会给所有子线程发送一个信号,使所有子线程强制结束。
子线程的创建方式:
很简单,直接使用thread类就可以了。括号中只要是一个可调用对象就没有问题了。
子线程创建后如果就不管了,那么会出现非常严重的问题。
(1) 有些子线程负责对部分数据的处理,主线程必须要等到子线程处理完毕才能继续执行,所以join函数就诞生了。
#include <iostream>
#include <thread>
int main() {
std::thread myThread([]() {
std::cout << "hello world" << std::endl;
});
myThread.join();
}使用了join函数后,主线程就会处于挂起状态,直到子线程执行完毕才可以继续执行。 (2) 有些子线程和主线程完全分离,各自执行各自的。但主线程执行完毕,**子线程就会立马被强制结束,容易导致各种bug,查都不知道从哪里开始查。**于是deatch函数就诞生了。
#include <iostream>
#include <thread>
int main() {
std::thread myThread([]() {
for (int i = 0;i<1000000;++i) {
}
});
myThread.detach();
}detach()函数可以让子线程被C++运行库接管,就算主线程执行完毕,子线程也会由C++运行时库清理相关资源。保证不会出现各种意想不到的bug。
传递线程参数
(1) 传递子线程函数的参数:
直接传递即可,注意:传递参数分为三种方式,值传递,引用传递,指针传递。
#include <iostream>
#include <thread>
void test(int i, const int& refI, const int* pi) {
}
int main() {
int i = 100;
std::thread myThread(test, i, i, &i);
//只有指针是同一个地址
}(2) 传递参数注意事项:
① 在使用detach时不要传递指针,或者说在设置子线程函数时,不要设置指针参数。因为值传递和引用传递并未直接传递地址,而指针传递却直接传递地址。所以当使用deatch时,传指针就会导致错误,指针已经被系统回收,所以不要千万不要传指针。
② 在使用detach时不要使用隐式类型转化,因为很有可能子线程参数还没来的及将参数转化为自己的类型,主线程就已经执行完毕了。
#include <iostream>
#include <thread>
#include <Windows.h>
class Test {
public:
Test(int i) {
std::cout << std::this_thread::get_id() << std::endl;
}
};
void test(const Test& str) {
std::cout << "child thread id is : " << std::this_thread::get_id() << std::endl;
}
int main() {
std::cout << "main thread id is : " << std::this_thread::get_id() << std::endl;
int i = 100;
std::thread myThread(test, i);
Sleep(1);
}(3) 总结:
① 普通类型在传递子线程函数参数时,直接值传递即可。 ② 类类型传递引用就可以了,类类型传递引用会首先调用一次复制构造函数生成一个临时变量,故而导致地址不相同。如果采用值传递,需要两次复制构造函数,开销更大。
#include <iostream>
#include <thread>
#include <Windows.h>
class Test {
public:
Test(int i) {
std::cout << std::this_thread::get_id() << std::endl;
}
Test(const Test& test) {
std::cout << "Test(const Test& test)" << std::endl;
}
};
void test(const Test& str) {
std::cout << "child thread id is : " << std::this_thread::get_id() << std::endl;
}
int main() {
std::cout << "main thread id is : " << std::this_thread::get_id() << std::endl;
Test t(100);
std::thread myThread(test, t);
myThread.join();
}(4) std::ref的用法:
根据刚才的演示,使用普通的引用传递会调用一次复制构造函数,导致函数无法对引用对象进行修改,于是std::ref诞生了,它可以使子线程在传递参数时不再调用复制构造函数。
int main() {
std::cout << "main thread id is : " << std::this_thread::get_id() << std::endl;
Test t(100);
std::thread myThread(test, std::ref(t));
myThread.join();
}线程id的概念
线程id定义:
每个线程都有自己的id,不管是主线程还是子线程都有自己的id。直接使用std::this_thread::get_id()就可以获得当前线程的id。
注意:
线程是依附于进程存在的,所以不同的进程可以有相同的线程id。
这一课很简单,但这个知识点不知道往哪里放,就单独拿出来了。
数据共享与数据保护
执行顺序
多个线程的执行顺序是乱的,具体执行方法和处理器的调度机制有关系。从开发者的角度讲,就是没有规律的。
#include <iostream>
#include <thread>
unsigned g_num = 0;
void test() {
++g_num;
}
int main() {
std::thread myThread(test);
myThread.detach();
return 0;
}拓展知识
在讲数据保护问题之前,为了帮助大家理解数据保护问题,这里额外扩展一些关于汇编的知识。科班的同学应该很熟悉,给非科班的人介绍一下。
一个进程运行时,数据存储在内存中。如果一个数据要进行运算,必须先将数据拷贝到寄存器中。比如要对栈上的一个int i进行++操作,需要将i的值拷贝到寄存器中,将该值自加后再拷贝到原来的内存。
如果此时有两个线程均进行的是这样的操作,可能出现两个进程都拷贝了i原来的值到寄存器,然后各种加一,再拷贝到i对应内存的情况,最终导致i这个变量只自加了一次。
这是同时写数据的情况,那么一读一写呢?这也是有问题的,谁知道读数据时写数据步骤已经到了哪里,谁知道读出来的是个什么东西。
数据保护问题:
数据保护问题总共有三种情况: ① 至少两个线程对共享数据均进行读操作,完全不会出现数据安全问题。 ② 至少两个线程对共享数据均进行写操作,会出现数据安全问题,需要数据保护。 ③ 至少两个线程对共享数据有的进行读,有的进行写,也会出现数据安全问题,需要进行数据保护。
#include <iostream>
#include <thread>
unsigned g_num = 0;
void test() {
for (unsigned i = 0;i< 10000000;++i) {
++g_num;
}
}
int main() {
std::thread myThread(test);
for (unsigned i = 0;i< 10000000;++i) {
++g_num;
}
myThread.join();
std::cout << g_num << std::endl;
return 0;
}数据保护的方法一共就两种:互斥锁,原子操作。
互斥锁:
(1) 互斥锁的作用原理很简单,对共享数据加锁,当一个线程对这块数据进行操作时,别的线程就无法对该区域数据进行操作。
#include <iostream>
#include <thread>
#include <mutex>
unsigned g_num = 0;
std::mutex myMutex;
void test() {
myMutex.lock();
for (unsigned i = 0;i< 10000000;++i) {
++g_num;
}
myMutex.unlock();
}
int main() {
std::thread myThread(test);
myMutex.lock();
for (unsigned i = 0;i< 10000000;++i) {
++g_num;
}
myMutex.unlock();
myThread.join();
std::cout << g_num << std::endl;
return 0;
}(2) 这种方式的互斥锁有个弊端,就是lock()之后容易忘记unlock(),就和指针类似。于是和智能指针类似,也有了lock_guard,用来防止开发人员忘了解锁。
#include <iostream>
#include <thread>
#include <mutex>
unsigned g_num = 0;
std::mutex myMutex;
void test() {
std::lock_guard<std::mutex> lg(myMutex);
for (unsigned i = 0;i< 10000000;++i) {
++g_num;
}
}
int main() {
std::thread myThread1(test);
std::thread myThread2(test);
myThread1.join();
myThread2.join();
std::cout << g_num << std::endl;
return 0;
}原子操作:(使用频率远远不及互斥锁)
(3) 原子操作的原理:将一个数据设置为原子状态,使得该数据处于无法被分割的状态,意思就是处理器在处理被设置为原子状态的数据时,其它处理器无法处理该段数据,该处理器也会保证在处理完该数据之前不会处理其他数据。
#include <iostream>
#include <thread>
#include <atomic>
std::atomic<unsigned> g_num = 0;
void test() {
for (unsigned i = 0;i< 10000000;++i) {
++g_num;
}
}
int main() {
std::thread myThread1(test);
std::thread myThread2(test);
myThread1.join();
myThread2.join();
std::cout << g_num << std::endl;
return 0;
}总结:在编写多线程代码时,数据保护是一个必须考虑,非常常用的功能。互斥锁的使用频率是远远高于原子操作,原子操作看似简单,但当需要保护的数据很多时,就会极其复杂。 所以:对于单个数据,可以使用原子操作,其它的使用互斥锁就可以了。
死锁
死锁就像两个人在互相等对方。A说,等B来了就去B现在所在的地方;B说,等A来了我就去A所在的地方,结果就是A和B都在等对面过来才能去对面。这就导致了一个死循环,放在多线程中,就是死锁。
unsigned g_num = 0;
std::mutex myMutex1;
std::mutex myMutex2;
void test() {
for(unsigned i = 0;i<100000;++i) {
std::lock_guard<std::mutex> lg1(myMutex1);
/*
*/
std::lock_guard<std::mutex> lg2(myMutex2);
++g_num;
}
}
int main() {
std::thread myThread(test);
for(unsigned i = 0;i<100000;++i) {
std::lock_guard<std::mutex> lg2(myMutex2);
/*
*/
std::lock_guard<std::mutex> lg1(myMutex1);
++g_num;
}
myThread.join();
}解决方法也很简单。
- 只要让两个锁顺序一致就可以了。
unsigned g_num = 0;
std::mutex myMutex1;
std::mutex myMutex2;
void test() {
for(unsigned i = 0;i<100000;++i) {
std::lock_guard<std::mutex> lg1(myMutex1);
/*
*/
std::lock_guard<std::mutex> lg2(myMutex2);
++g_num;
}
}
int main() {
std::thread myThread(test);
for(unsigned i = 0;i<100000;++i) {
std::lock_guard<std::mutex> lg1(myMutex1);
std::lock_guard<std::mutex> lg2(myMutex2);
++g_num;
}
myThread.join();
}- 但是让两个锁顺序一致常常是说起来容易,做起来难。于是C++11提供了std::lock。这个模板可以保证多个互斥锁绝对不会出现死锁的问题。同时提供了std::adopt_lock的功能来避免忘记释放锁的问题。
unsigned g_num = 0;
std::mutex myMutex1;
std::mutex myMutex2;
void test() {
for(unsigned i = 0;i<100000;++i) {
std::lock(myMutex1, myMutex2);
std::lock_guard<std::mutex> lg1(myMutex1, std::adopt_lock);
/*
*/
std::lock_guard<std::mutex> lg2(myMutex2, std::adopt_lock);
++g_num;
}
}
int main() {
std::thread myThread(test);
for(unsigned i = 0;i<100000;++i) {
std::lock(myMutex1, myMutex2);
std::lock_guard<std::mutex> lg1(myMutex1, std::adopt_lock);
/*
*/
std::lock_guard<std::mutex> lg2(myMutex2, std::adopt_lock);
++g_num;
}
myThread.join();
}总结:死锁是一个比较常见的bug,面试时也经常询问死锁相关的知识。
这一节课上完,多线程的主体部分就讲完了,后面都是使用频率较低的东西,也就是个补充。所以前六节课必须学会,每一课都是重点。后面的我就不讲了。
