首先两个重要概念
什么是并发和并行
并发就是多个线程在同一个时间段内共同执行
并行就是多个线程在同一个时间点共同执行的线程
总结:
- 多线程是并发最常用的实现方式,好处是任务并行、避免阻塞,坏处是开发难度高,有数据竞争、死锁等很多“坑”;
- call_once() 实现了仅调用一次的功能,避免多线程初始化时的冲突;
- thread_local 实现了线程局部存储,让每个线程都独立访问数据,互不干扰;
- atomic 实现了原子化变量,可以用作线程安全的计数器,也可以实现无锁数据结构;
- async() 启动一个异步任务,相当于开了一个线程,但内部通常会有优化,比直接使用线程更好。
什么是并发,多线程,进程,线程,协程?
通俗地说,“并发”是指在一个时间段里有多个操作在同时进行,与“多线程”并不是一回事。
并发有很多种实现方式,而多线程只是其中最常用的一种手段。不过,因为多线程已经有了很多年的实际应用,也有很多研究成果、应用模式和成熟的软硬件支持,所以,对这两者的区分一般也不太严格,下面我主要来谈多线程。
进程
是操作系统资源分配的基本单位
线程
是操作系统调度的基本单位
协程
是微线程,存在于线程中,是比线程更小的可执行单元
1:线程定义
线程是进程的基本执行单元,一个进程的所有任务都在线程中执行
进程要想执行任务,必须得有线程,进程至少要有一条线程
程序启动会默认开启一条线程,这条线程被称为主线程或 UI 线程
2:进程定义
进程是指在系统中正在运行的一个应用程序
每个进程之间是独立的,每个进程均运行在其专用的且受保护的内存
3:进程与线程的区别
地址空间:同一进程的线程共享本进程的地址空间,而进程之间则是独立的地址空间。
资源拥有:同一进程内的线程共享本进程的资源如内存、I/O、cpu等,但是进程之间的资源是独立的。
一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。
进程切换时,消耗的资源大,效率高。所以涉及到频繁的切换时,使用线程要好于进程。同样如果要求同时进行并且又要共享某些变量的并发操作,只能用线程不能用进程
执行过程:每个独立的进程有一个程序运行的入口、顺序执行序列和程序入口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。
线程是处理器调度的基本单位,但是进程不是。
4:多线程的意义
优点
能适当提高程序的执行效率
能适当提高资源的利用率(CPU,内存)
线程上的任务执行完成后,线程会自动销毁
缺点
开启线程需要占用一定的内存空间(默认情况下,每一个线程都占 512 KB)
如果开启大量的线程,会占用大量的内存空间,降低程序的性能
线程越多,CPU 在调用线程上的开销就越大
程序设计更加复杂,比如线程间的通信、多线程的数据共享
5:多线程的原理
(单核cpu)同一时间,cpu只能处理 1 个线程。换言之,同一时间只有 1 个线程在执行
多线程同时执行:
*是 cpu 快速的在多个线程之间的切换
- cpu 调度线程的时间足够快,就造成了多线程的“同时”执行效果
如果线程数非常多 - cpu 会在 N 个线程直接切换,消耗大量的 cpu 资源
- 每个线程调度的次数会降低,线程的执行效率降低
作者:heart_领
链接:https://www.jianshu.com/p/39d65ffa181f
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
认识线程和多线程
要掌握多线程,就要先了解线程(thread)。
线程的概念可以分成好几个层次,从 CPU、操作系统等不同的角度看,它的定义也不同。今天,我们单从语言的角度来看线程。
在 C++ 语言里,线程就是一个能够独立运行的函数。比如你写一个 lambda 表达式,就可以让它在线程里跑起来:
auto f = []() // 定义一个lambda表达式
{
cout << "tid=" <<
this_thread::get_id() << endl;
};
thread t(f); // 启动一个线程,运行函数f
任何程序一开始就有一个主线程,它从 main() 开始运行。主线程可以调用接口函数,创建出子线程。**子线程会立即脱离主线程的控制流程,单独运行,但共享主线程的数据。**程序创建出多个子线程,执行多个不同的函数,也就成了多线程。
多线程的好处你肯定能列出好几条,比如任务并行、避免 I/O 阻塞、充分利用 CPU、提高用户界面响应速度,等等。
不过,多线程也对程序员的思维、能力提出了极大的挑战。不夸张地说,它带来的麻烦可能要比好处更多。
这个问题相信你也很清楚,随手就能数出几个来,比如同步、死锁、数据竞争、系统调度开销等……每个写过实际多线程应用的人,可能都有“一肚子的苦水”。
其实,多线程编程这件事“说难也不难,说不难也难”。这句话听上去好像有点自相矛盾,但却有一定的道理。为什么这么说呢?
**说它不难,**是因为线程本身的概念是很简单的,只要规划好要做的工作,不与外部有过多的竞争读写,很容易就能避开“坑”,充分利用多线程,“跑满”CPU。
说它难,则是因为现实的业务往往非常复杂,很难做到完美的解耦。一旦线程之间有共享数据的需求,麻烦就接踵而至,因为要考虑各种情况、用各种手段去同步数据。随着线程数量的增加,复杂程度会以几何量级攀升,一不小心就可能会导致灾难性的后果。
了解下标准库为多线程编程提供了哪些工具,在语言层面怎么改善多线程应用。有了这个基础,你再去看那些专著时,就可以省很多力气,开发时也能少走些弯路。
最重要的点
首先,你要知道一个最基本但也最容易被忽视的常识:“读而不写”就不会有数据竞争。
所以,在 C++ 多线程编程里读取 const 变量总是安全的,对类调用 const 成员函数、对容器调用只读算法也总是线程安全的。
多用 const 关键字,尽可能让操作都是只读的,为多线程打造一个坚实的基础。
然后,我要说一个多线程开发的原则,也是一句“自相矛盾”的话:
最好的并发就是没有并发,最好的多线程就是没有线程。
这又是什么意思呢?
简单来说,就是在大的、宏观的层面上“看得到”并发和线程,而在小的、微观的层面上“看不到”线程,减少死锁、同步等恶性问题的出现几率。
多线程开发实践
下面,我就来讲讲具体该怎么实践这个原则。在 C++ 里,有四个基本的工具:仅调用一次、线程局部存储、原子变量和线程对象。
仅调用一次
程序免不了要初始化数据,这在多线程里却是一个不大不小的麻烦。因为线程并发,如果没有某种同步手段来控制,会导致初始化函数多次运行。
为此,C++ 提供了“仅调用一次”的功能,可以很轻松地解决这个问题。
这个功能用起来很简单,你要先声明一个 once_flag 类型的变量,最好是静态、全局的(线程可见),作为初始化的标志:
static std::once_flag flag; // 全局的初始化标志
然后调用专门的 call_once() 函数,以函数式编程的方式,传递这个标志和初始化函数。这样 C++ 就会保证,即使多个线程重入 call_once(),也只能有一个线程会成功运行初始化。
下面是一个简单的示例,使用了 lambda 表达式来模拟实际的线程函数。
auto f = []() // 在线程里运行的lambda表达式
{
std::call_once(flag, // 仅一次调用,注意要传flag
[](){ // 匿名lambda,初始化函数,只会执行一次
cout << "only once" << endl;
} // 匿名lambda结束
); // 在线程里运行的lambda表达式结束
};
thread t1(f); // 启动两个线程,运行函数f
thread t2(f);
call_once() 完全消除了初始化时的并发冲突,在它的调用位置根本看不到并发和线程。所以,按照刚才说的基本原则,它是一个很好的多线程工具。
它也可以很轻松地解决多线程领域里令人头疼的“双重检查锁定”问题,你可以自己试一试,用它替代锁定来初始化。
线程局部存储
读写全局(或者局部静态)变量是另一个比较常见的数据竞争场景,因为共享数据,多线程操作时就有可能导致状态不一致。
但如果仔细分析的话,你会发现,有的时候,全局变量并不一定是必须共享的,可能仅仅是为了方便线程传入传出数据,或者是本地 cache,而不是为了共享所有权。
换句话说,这应该是线程独占所有权,不应该在多线程之间共同拥有,术语叫“线程局部存储”(thread local storage)。
这个功能在 C++ 里由关键字 thread_local 实现,它是一个和 static、extern 同级的变量存储说明,有 thread_local 标记的变量在每个线程里都会有一个独立的副本,是“线程独占”的,所以就不会有竞争读写的问题。
下面是示范 thread_local 的代码,先定义了一个线程独占变量,然后用 lambda 表达式捕获引用,再放进多个线程里运行:
thread_local int n = 0; // 线程局部存储变量
auto f = [&](int x) // 在线程里运行的lambda表达式,捕获引用
{
n += x; // 使用线程局部变量,互不影响
cout << n; // 输出,验证结果
};
thread t1(f, 10); // 启动两个线程,运行函数f
thread t2(f, 20);
你可以试着把变量的声明改成 static,再运行一下。这时,因为两个线程共享变量,所以 n 就被连加了两次,最后的结果就是 30。
static int n = 0; // 静态全局变量
... // 代码与刚才的相同
和 call_once() 一样,thread_local 也很容易使用。但它的应用场合不是那么显而易见的,这要求你对线程的共享数据有清楚的认识,区分出独占的那部分,消除多线程对变量的并发访问。
原子变量
那么,对于那些非独占、必须共享的数据,该怎么办呢?
要想保证多线程读写共享数据的一致性,关键是要解决同步问题,不能让两个线程同时写,也就是“互斥”。
这在多线程编程里早就有解决方案了,就是互斥量(Mutex)。但它的成本太高,所以,对于小数据,应该采用“原子化”这个更好的方案。
所谓原子(atomic),在多线程领域里的意思就是不可分的。操作要么完成,要么未完成,不能被任何外部操作打断,总是有一个确定的、完整的状态。
所以也就不会存在竞争读写的问题,不需要使用互斥量来同步,成本也就更低。
但不是所有的操作都可以原子化的,否则多线程编程就太轻松了。目前,C++ 只能让一些最基本的类型原子化,比如 atomic_int、atomic_long,等等:
using atomic_bool = std::atomic<bool>; // 原子化的bool
using atomic_int = std::atomic<int>; // 原子化的int
using atomic_long = std::atomic<long>; // 原子化的long
这些原子变量都是模板类 atomic 的特化形式,包装了原始的类型,具有相同的接口,用起来和 bool、int 几乎一模一样,但却是原子化的,多线程读写不会出错。
注意,我说了“几乎”这个词。它还是有些不同的,一个重要的区别是,原子变量禁用了拷贝构造函数,所以在初始化的时候不能用“=”的赋值形式,只能用圆括号或者花括号:
atomic_int x {0}; // 初始化,不能用=
atomic_long y {1000L}; // 初始化,只能用圆括号或者花括号
assert(++x == 1); // 自增运算
y += 200; // 加法运算
assert(y < 2000); // 比较运算
除了模拟整数运算,原子变量还有一些特殊的原子操作,比如 store、load、fetch_add、fetch_sub、exchange、compare_exchange_weak/compare_exchange_strong,最后一组就是著名的 CAS(Compare And Swap)操作。
而另一个同样著名的 TAS(Test And Set)操作,则需要用到一个特殊的原子类型 atomic_flag。
它不是简单的 bool 特化(atomic),没有 store、load 的操作,只用来实现 TAS,保证绝对无锁。
你能用这些原子变量做些什么呢?
最基本的用法是把原子变量当作线程安全的全局计数器或者标志位,这也算是“初心”吧。但它还有一个更重要的应用领域,就是实现高效的无锁数据结构(lock-free)。
但我强烈不建议你自己尝试去写无锁数据结构,因为无锁编程的难度比使用互斥量更高,可能会掉到各种难以察觉的“坑”(例如 ABA)里,最好还是用现成的库。
遗憾的是,标准库在这方面帮不了你,虽然网上可以找到不少开源的无锁数据结构,但经过实际检验的不多,我个人觉得你可以考虑 boost.lock_free。
线程
到现在我说了 call_once、thread_local 和 atomic 这三个 C++ 里的工具,它们都不与线程直接相关,但却能够用于多线程编程,尽量消除显式地使用线程。
但是,必须要用线程的时候,我们也不能逃避。
C++ 标准库里有专门的线程类 thread,使用它就可以简单地创建线程,在名字空间 std::this_thread 里,还有 yield()、get_id()、sleep_for()、sleep_until() 等几个方便的管理函数。因为它们的用法比较简单,资料也随处可见,我就不再重复了。
下面的代码同时示范了 thread 和 atomic 的用法:
static atomic_flag flag {false}; // 原子化的标志量
static atomic_int n; // 原子化的int
auto f = [&]() // 在线程里运行的lambda表达式,捕获引用
{
auto value = flag.test_and_set(); // TAS检查原子标志量
if (value) {
cout << "flag has been set." << endl;
} else {
cout << "set flag by " <<
this_thread::get_id() << endl; // 输出线程id
}
n += 100; // 原子变量加法运算
this_thread::sleep_for( // 线程睡眠
n.load() * 10ms); // 使用时间字面量
cout << n << endl;
}; // 在线程里运行的lambda表达式结束
thread t1(f); // 启动两个线程,运行函数f
thread t2(f);
t1.join(); // 等待线程结束
t2.join();
但还是基于那个原则,我建议你不要直接使用 thread 这个“原始”的线程概念,最好把它隐藏到底层,因为“看不到的线程才是好线程”。
具体的做法是调用函数 async(),它的含义是“异步运行”一个任务,隐含的动作是启动一个线程去执行,但不绝对保证立即启动(也可以在第一个参数传递 std::launch::async,要求立即启动线程)。
大多数 thread 能做的事情也可以用 async() 来实现,但不会看到明显的线程:
auto task = [](auto x) // 在线程里运行的lambda表达式
{
this_thread::sleep_for( x * 1ms); // 线程睡眠
cout << "sleep for " << x << endl;
return x;
};
auto f = std::async(task, 10); // 启动一个异步任务
f.wait(); // 等待任务完成
assert(f.valid()); // 确实已经完成了任务
cout << f.get() << endl; // 获取任务的执行结果
其实,这还是函数式编程的思路,在更高的抽象级别上去看待问题,异步并发多个任务,让底层去自动管理线程,要比我们自己手动控制更好(比如内部使用线程池或者其他机制)。
async() 会返回一个 future 变量,可以认为是代表了执行结果的“期货”,如果任务有返回值,就可以用成员函数 get() 获取。
不过要特别注意,get() 只能调一次,再次获取结果会发生错误,抛出异常 std::future_error。(至于为什么这么设计我也不太清楚,没找到官方的解释)
另外,这里还有一个很隐蔽的“坑”,如果你不显式获取 async() 的返回值(即 future 对象),它就会同步阻塞直至任务完成(由于临时对象的析构函数),于是“async”就变成了“sync”。
所以,即使我们不关心返回值,也总要用 auto 来配合 async(),避免同步阻塞,就像下面的示例代码那样:
std::async(task, ...); // 没有显式获取future,被同步阻塞
auto f = std::async(task, ...); // 只有上一个任务完成后才能被执行
标准库里还有 mutex、lock_guard、condition_variable、promise 等很多工具,不过它们大多数都是广为人知的概念在 C++ 里的具体实现,用法上没太多新意,所以我就不再多介绍了。