学习C++11线程池项目过程中对线程开发的一些知识点进行记录学习
线程 std::thread
- 创建一个线程
// 默认构造函数,创建一个空的线程执行对象
std::thread();
// 初始化构造函数
template<class T, class... Args>
explict thread(T&& fn, Args&&... args);
// 创建一个简单的线程,提供一个线程函数或者函数对象,同时指定函数所需的参数
void func1()
{
printf("into func1\n");
}
void func2(int a)
{
printf("func2:%d\n", a);
}
void func3(int a)
{
printf("func3:%d\n", a);
}
int main()
{
std::thread td1 = std::thread(func1);
td1.join();
std::thread td2 = std::thread(threadFunc, 1);
td2.join();
std::thread td3 = std::thread( std::bind(func3, 3) );
td3.join();
return 0;
}
- 常用成员函数
- get_id(); //获取当前线程ID,返回std::thread:id类型
- joinable(); // 判断当前线程是否可以加入等待,返回bool类型
- join(); // 使当前线程加入等待,线程函数执行结束后返回
- detach(); // 将当前线程从调用线程中分离,使其独立运行,无法被主动kill,只有线程函数执行结束或者主进程结束的时候,才会被回收
- thread可以绑定普通函数、成员函数、静态函数、仿函数、std::function对象、std::bind的返回值(就是跟绑定对象具有相同函数签名的std::function对象)以及lambda表达式
线程同步的方式
互斥量 std::mutex
- C++11中最基本的互斥量,对共享资源进行独占式加锁
- lock(),调用后线程将锁住mutex对象,会出现三种情况 (1). 如果该互斥量当前没有被锁住,则调用线程将该互斥量锁住,直到调用unlock之前,该线程一直拥有该锁。(2). 如果当前互斥量被其他线程锁住,则当前的调用线程被阻塞住。(3). 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)。
- unlock(),调用后当前线程释放mutex的所有权,即释放锁
- try_lock(),尝试锁住mutex对象,与lock效果类似,不同的是,如果此时其他线程锁住该互斥量,则try_lock会返回false,不会导致当前线程阻塞。
- 为避免使用者未在正确的时候释放锁而导致多线程出现异常,C++11提供了lock_guard和unique_lock的使用,采用RAII的方式(初始化即创建,析构函数即释放)实现手动上锁和自动释放释放锁
- 除了mutex,C++11还提供了递归互斥量std::recursive_mutex和带超时的互斥量std::timed_mutex和 std::recursive_timed_mutex
- 递归互斥量,允许当前线程递归上锁,解决使用mutex同一线程多次上锁导致死锁的问题
- 超时互斥量,提供了try_lock_for和try_Lock_until两个成员函数,当超时发生时会自动释放锁,避免长时间死锁
std::mutex mtx;
void test()
{
{
std::lock_guard lck (mtx); // 上锁
...
// 操作共享资源
...
}
// 离开作用域 mtx自动释放
{
std::unique_lock<std::mutex> lk(mutex); // 上锁
...
do something;
lk.unlock(); // unique_lock和lock_guard用法类似,区别在于unique_lock支持中途主动释放锁
...
lk.lock(); // 也可以在释放后,手动再次上锁,因为这种特性,所以在使用条件变量时采用的是unique_lock,而不是lock_guard
do something;
...
}
// 离开作用域,mtx同样因为lk的析构而释放
}
条件变量
- 互斥量是保证互斥资源被同步访问的保证,但单靠互斥量却实现不了线程的同步调用,即按照可调度方式有序执行的行为。为此C++11提供了此类行为的支持----条件变量
- 条件变量需要结合std::mutex和std::unique_lock一起使用
- wait(),有两种重载方式,一种是只有unique_Lock对象,另一种是包含一种等待条件。其工作原理:
- 当线程调用wait()后将被阻塞,并且会释放当前线程所获取的互斥量,直到其他线程中调用了notify_one或者notify_all,此时当前阻塞线程被唤醒
- 如果采用第一种重载方式,第一次调用时直接解锁并阻塞,被notify唤醒后,wait重新尝试获取锁,如果得不到则继续阻塞在本行,如果成功获取到锁,则wait()返回,并继续调用后续操作。
- 如果采用第二种重载方式,则在被唤醒的时候需要判断等待条件是否为true,如果为true则尝试获取锁,成功则返回,失败则继续阻塞;如果等待条件为false,那么就继续进行阻塞
std::mutex mtx;
std::condition_variable cv;
bool b_ready = false;
void threadFuc()
{
while(1)
{
std::unique_lock<std::mutex> lk(mtx); // 上锁
// 无等待条件
cv.wait(lk); // 解锁,且进入阻塞状态,直达外部调用cv.notify_one或者cv.notify_all
// 有等待条件
cv.wait(lk, []{return b_ready;}); // 调用notify_one或者notify_all后,还需要b_ready为真,才能重新获取锁
}
}
- wait_for(),在wait的基础上添加了一个超时时间段,cv.wait_or(lk, timeouts, []{return b_ready;});,当运行timeouts时长内或者未收到唤醒通知时,都会处于阻塞状态,否则wait_for将返回,其返回方式跟wait是类似的,只是多出了一种返回的可能性,即产生超时
- wait_until(),用法与wait_for()类似,其区别在于对时间参数的定义不同,这里的时间使用的是一个时间点,在到达某个时间点之前或者未收到唤醒通知,当前线程处于阻塞状态;否则wait_until将返回
- notify_one(),唤醒一个当前处于阻塞的线程对象,只有一个等待中线程会响应到该通知
- notify_all(), 唤醒所有处于阻塞的线程对象
信号量
- counting_semaphore,类似C语言中的sem_t类型,在创建时可以指定信号量的计数,通过P&V操作来控制信号量的计数实现对共享资源的并发访问(C++20中提供支持,须使用vs2019版本以上)
- 当线程想访问共享资源时,此时信号量计数必须是大于0的,成功获得信号量之后,可以访问共享资源,当前信号量计数减1;共享资源操作结束后,须释放当前信号量,此时计数加1
- 如果信号量计数不大于0,则无法获取该信号量,阻塞挂起,等待信号量计数恢复
- 信号量计数的加减操作是原子性的,如果信号量初始化为1,则表明同时只能有1个对象可以访问共享资源
std::counting_semaphore sm(1); //初始化信号量为1
void threadproc()
{
sm.acquire(); //获取资源 ,相当于P操作,计数-1,类似sem_t::wait()
...
do something;
...
sm.release(1); //释放资源,相当于V操作, 计数+1,类似sem_t::post()
}
线程异步操作std::future
- std::future其效果类似通过一个对象来记录一个事件,并可以在事件完成后由线程主动获取事件的执行结果,简单来说就是可以异步获取某个函数的返回值。在实际应用场景中,线程中执行的函数其他线程是无法感知其返回值的,通常通过传递一个引用类型的参数来获取执行状态,但无法实现同步感知,此时就可以通过std::future来实现。
- 线程可以周期性的在这个future上等待一小段时间(get()),可以通过wait()来等待异步结果的输入,检查future是否已经就绪(即事件是否已经返回),如果没有,该线程可以先去做另一个任务;一旦future就绪,可以通过future对象来获取执行结果。future只代表一次事件,无法多次获取
- 如果需要多次获取,则需要使用std::shared_future来达到多次get的目的。原理与shared_ptr类似
如何得到一个std::future对象?
C++11提供了多种可以返回future对象的方式,包括std::async/std::packaged_task/std::promise,这几种方法都是通过绑定一个可调用对象来实现。
std::async
// 需要包含此头文件
#include <future>
int func(int a)
{
a++;
...
return a;
}
int main()
{
// async跟thread类似,支持传递参数到函数调用中
std::future result = std::async(func, 1);
...
do other tings;
...
std::cout << "func return: " << result.get() << std::endl;
}
- std::async 并不总会开启新的线程来执行任务,你可以指定
std::launch::async
来强制开启新线程
auto f = std::async(std::launch::async, func);
std::packaged_task
- std::packaged_task实现了对任务的封装,其模板参数是绑定函数的函数签名, 如int test(int a, int b),则其函数签名为<int(int,int)>
- std::packaged_task的返回类型是void,获取绑定事件的返回值必须先调用get_future()来获取future对象
int func(int a)
{
a++;
...
}
int main()
{
std::packaged_task<int(int)> t(func);
t(); // 这样使用类似就是一个仿函数,get()是不会阻塞的
std::cout << "func return: " << t.get_futrue().get() << std::endl;
//将任务抛到线程中执行,在调用get()就会阻塞,直到t执行结束
std::thread t { std::move(t), 5 };
std::cout << "func return: " << t.get_futrue().get() << std::endl;
}
std::promise
-promise提供了一个设置值的方式,可以通过与之关联的future对象进行读取,默认的future就绪条件是绑定函数执行完毕,而promise额外提供了可以主动触发future就绪的方式。
void test(std::promise<int> && p)
{
p.set_value(8); // 执行线程对promise对象进行设置,设置成功
}
int main()
{
std::promise<int> promise; // 创建一个promise对象
std::future<int> result = promise.get_future(); // 得到其future对象
std::thread t(test, std::ref(promise)); // 将promise对象传递到执行线程中
...
do other things;
...
std::cout << result.get() << std::endl; // p.set_value(8)执行后,才可以通过get()读取到当前值
t.join();
}
总结
- 可以看出promis跟packaged_task的future对象创建时机有所不同;promise在将自己传递给线程之前,线程外是已经可以持有future;但是promise将我们所关心的异步数据在自行调用set_value后才可以通过get返回,其抽象程度相对较低
- std::async用于创建异步任务,使用最简单,其抽象程度最高
- std::packaged_task则将一个可调用对象(包括函数、函数对象、lambda表达式、std::bind表达式、std::function对象)进行包装,以便该任务能被异步调用(即在其他线程中调用)
- 二者均可通过std::future对象返回执行结果。二者使用的一个主要差别是:std::packaged_task需要等待执行结果返回,而std::async不必
可变模板参数
- C++11最强大的特性之一
- 模板语法
template <class... T>
void f(T... args);
- 上面的参数args前面有省略号,所以它就是一个可变模版参数,我们把带省略号的参数称为“参数包”,它里面包含了0到N(N>=0)个模版参数。我们无法直接获取参数包args中的每个参数的,只能通过展开参数包的方式来获取参数包中的每个参数。
- 定义一个可变模板参数函数
template<class... T>
void func(T... args)
{
std::cout << sizeof...(args) << std::endl; // 打印参数包中的参数个数
}
int main()
{
func(); // 0
func(1, 2); // 2
func(1, 2, "abc"); //3
return 0;
}
如何展开参数包?
- 递归函数 + 递归终止函数
/* 递归方式展开需要提供两个函数
1.递归展开
2.递归终止
*/
// 递归终止
void print()
{
// 参数包为空时,调用当前函数,递归开始返回,即递归终止
std::cout << "over" << std::endl;
}
// 递归终止也可以这么写
template <class T>
print(T head)
{
std::cout << "last Param:" << head << std::endl;
}
// 递归展开
template<class T, class... args>
void print(T head, T... args)
{
std::cout << "curParam:" << head << std::endl; // 打印参数包中的最左侧参数
print(args...); // 递归剩余参数包
}
int main()
{
print(1,2,3, "ok");
return 0;
}
- ... + 初始化列表 + 逗号表达式
- 逗号表达式就是逗号运算符,优先级别最低,将两个及以上的子表达式连起来,从左往右逐个计算,整个表达式的值为最后一个表达式的值
- ...可以将参数包展开
- ...结合初始化列表的使用方法
template <class... T>
void test(T... args)
{
auto tmp[] = {args...};
for(auto &v:tmp) // C++11范围遍历
{
std::cout << v << std::endl;
}
}
int main()
{
// 初始化列表要求参数类型是一致的,这里采用的是相同的参数类型组成的参数包
test(1,2,3); // 这里auto tmp = {1,2,3}; 会将args参数包展开到初始化列表中
test("a","b", "c"); // auto tmp = {a, b, c};
}
- 结合逗号表达式的使用方法
template <class T>
void printArg(T t)
{
std::cout << t <<std::endl;
}
template <class... Args>
void expand(Args... args)
{
// 如果参数类型不一致,这里就需要借助逗号表达式,对初始化列表的值进行处理
int arr[] = {(printArg(args), 0)...}
}
int main()
{
expand(1,2,3,4);
// 此时展开为
int arr[]={(printArg(1), 0), (printArg(2), 0),(printArg(3), 0),(printArg(4), 0)};
// 根据逗号表达式的计算方式,此时arr是一个被0填充的数组
return 0;
}