线程管理
多线程编程是开发中经常用的技术,多数情况下,我们只是知道怎么启线程、回收线程以及常规的一些用法,对于其具体技术细节以及还有哪些巧妙的用法并未挖掘。
本篇参考《C++并发编程实战》及其他优秀的博客,做一次对C++的线程管理的梳理,方便后续使用查阅。
并发编程的方法
计算机领域的并发指的是在单个系统里同时执行多个独立的任务, 而非顺序的进行一些活动。通常并发方式有两种: 多进程和多线程。
多进程并发
将场景任务以两个或以上进程实现,这些独立的进程相互通信,共同完成任务,称之为多进程并发。
由于操作系统对进程提供了大量的保护机制,以避免一个进程修改了另一个进程的数据,使用多进程比多线程更容易写出安全的代码。但这也造就了多进程并发的两个缺点:
- 使用信号、套接字,还是文件、管道等方式进行进程间通信,存在使用麻烦或者通信速度较慢等问题。
- 在创建或撤消进程时,由于系统都要为之分配和回收资源,导致系统的开销明显大于创建或撤消线程时的开销。
多线程并发
在同一个进程中执行多个线程,称之为多线程并发。
线程可看做是轻量级的进程,每个线程可以独立的运行不同的指令序列,但是线程不独立的拥有资源,依赖于创建它的进程而存在。也就是说,同一进程中的多个线程共享相同的地址空间,可以访问进程中的大部分数据,指针和引用可以在线程间进行传递。
相较于多进程间通信,多线程可利用共享的地址设计线程间的通信,这就使多线程通信更简单。另一方面,共享地址的滥用,也会导致程序异常。多线程并发一直值得程序员谨慎和敬畏,因此能不使用尽量不用。
线程管理基础
启动线程
线程在 std::thread 对象创建(为线程指定任务)时启动,在创建对象时会传入任务函数作为参数。此任务函数有两种方式:函数指针和lambda表达式:
// 函数指针形式
void thread1()
{
LOGD("--> This is thread1.\n");
}
std::thread th1(thread1);
// lambda形式
std::thread th2([]() {
LOGD("-> This is thread2.\n");
});
等待线程完成
假设进程内部的线程未使用join()或deatch(),会导致std::thread对象在销毁时,程序异常终止(无论全局还是局部线程)。
因此,每个线程在使用时,都要确定回收方式。若线程在局部函数启动时,要注意线程在局部销毁前回收。
std::thread
使用 join()
阻塞等待线程结束。调用 join()
的行为,还清理了线程相关的存储部分, 这样 std::thread
对象将不再与已经完成的线程有任何关联。
这意味着, 只能对一个线程使用一次 join()
一旦已经使用过 join()
, std::thread
对象就不能再次加入了, 当对其使用joinable()
时, 将返回否 (false)
std::thread th2([]() {
LOGD("-> This is thread2.\n");
});
th2.join();
特殊情况下等待
通过上述分析,好的程序员都应该在启动线程时,考虑好在何时回收线程(即使用join()或detach()的位置)。
借鉴《C++并发编程》的一种做法: 使用“资源获取即初始化方式”(RAII, Resource Acquisition Is Initialization), 并且提供一个类, 在析构函数中使用join(), 如同下面清单中的代码:
class thread_guard
{
std::thread& t;
public:
explicit thread_guard(std::thread& t_):
t(t_)
{}
~thread_guard()
{
if(t.joinable()) // 1
{
t.join(); // 2
}
}
thread_guard(thread_guard const&)=delete; // 3
thread_guard& operator=(thread_guard const&)=delete;
};
void f()
{
int some_local_state=0;
std::thread t([](){
LOGD("-> This is thread.\n");
});
thread_guard g(t);
do_something_in_current_thread();
} // 4
当线程执行到④处时, 局部对象就要被逆序销毁了。 因此, thread_guard对象g是第一个被销毁的, 这时线程在析构函数中被加入②到原始线程中。 即使do_something_in_current_thread抛出一个异常, 这个销毁依旧会发生。
后台运行
通过调用detach()
会使程序后台独立运行,即不会再与主线程直接交互。
如果线程分离,主线程就失去了对分离线程的控制权,即无法再捕获分离线程,自然也无法再join
此线程。即使主线程结束,分离线程可能还在运行,此时由C++运行时库负责清理与子线程相关的资源。
分离线程一般用于执行时间过长的线程,使用join()
会导致主线程长时间阻塞。
向线程函数传递参数
线程函数传参,是在线程启动时向任务函数传递参数。两种启动线程的方式分别对应以下的传参形式:
// 函数指针
void thread1(const char* name)
{
LOGD("--> This is %s.\n", name);
}
std::thread th1(thread1, "thread1");
// lambda
std::thread th2([](const char *name) {
LOGD("-> This is %s.\n", name);
}, "thread2");
注: 当参数为字符串常量(如"thread1")或者字符串变量时,任务函数参数类型应为const char*。
转移线程所有权
转移线程所有权是将一个线程的任务函数的控制权转移到另一个线程。
转移所有权,我理解的是在局部函数或特定阶段,能够随意控制指定线程而不受外部影响,另外也会减少资源开销。
std::thread 支持移动的好处是可以创建thread_guard类的实例, 并且拥有其线程的所有权。 当thread_guard对象所持有的线程已经被引用, 移动操作就可以避免很多不必要的麻烦; 这意味着, 当某个对象转移了线程的所有权后, 它就不能对线程进行加入或分离。 为了确保线程程序退出前完成,下面的代码里定义了scoped_thread类。
class scoped_thread
{
std::thread t;
public:
explicit scoped_thread(std::thread t_): // 1
t(std::move(t_))
{
if(!t.joinable()) // 2
throw std::logic_error(“No thread”);
}
~scoped_thread()
{
t.join(); // 3
}
scoped_thread(scoped_thread const&)=delete;
scoped_thread& operator=(scoped_thread const&)=delete;
};
void f()
{
int some_local_state;
scoped_thread t(std::thread t([]() {
LOGD("-> This is thread.\n");
});); // 4
do_something_in_current_thread();
} // 5
运行时决定线程数量
std::thread::hardware_concurrency()
这个函数用于获取程序可以调动的最大线程数,在多核系统中可能代表CPU核数。 这个函数返回值仅可以作为参考,因为有可能返回0。
识别线程
线程标识类型是 std::thread::id
, 可以通过两种方式进行检索:
- 线程内通过
std::this_thread::get_id()
获取线程ID。 - 线程外部通过
std::thread
对象的成员函数get_id()
获取。
{
std::thread th2([](const char *name) {
std::stringstream ss;
ss << std::this_thread::get_id(); // 1
LOGD("ID: %s -> This is %s.\n", ss.str().c_str(), name);
}, "thread2");
std::stringstream ss;
ss << th2.get_id(); // 2
LOGD("ID: %s -> This is %s.\n", ss.str().c_str(), "thread2");
th2.join();
}
①在线程th2任务函数内通过std::this_thread::get_id()
获取当前线程的ID。 ②在线程外部,通过th2成员函数get_id()
获取th2线程的ID。
总结
- 多线程并发是一种双刃剑,在涉及到多线程交互的设计时,一定要慎之又慎。能不用则不用,需要用时做好多线程共享数据设计。
- 相比Linux原生多线程接口,C++多线程封装的接口使用起来更方便。
参考
本文由mdnice多平台发布
网友评论