一、数据容器
在前面分析过,不管是线程任务的封装还是同步数据队列的封装,都是需要一个数据结构的。一用来说,如果没有什么特殊的原因,开发者都是使用STL中数据结构。比如前面经常见到的std::queue,std::deque,std::vector,std::unordered_map,std::list等等。
但是大家都知道,标准库中的容器一般都是非线程安全的,所以在线程池中如果想使用这些容器,就需要自己处理一下相关的线程安全相关的控制。
二、线程的安全性
所谓线程的安全数据访问,其实在前面学习了很多方法和技术。在不同的平台上,可能也有不小的差异。但标准库的出现还是让这种差异弥合了很多。一般来说,在保证线程数据安全访问,也就是同步控制上,使用锁。也就是lock来锁住mutex。
但如何安全的使用锁,这就有很多技巧了。包括锁的粒度的大小,锁使用的时机和应用的地方。在下面的例子中,大家可以分析一下,如何进行更好的优化。
三、接口封装
数据结构如果使用标准库的容器,就会有一个问题,标准库的不同的容器,其对外的接口的名称是不相同的。当然,如何不对这些容器进行泛化,只是单纯的使用某种容器,就没有这种问题。但是如何想使用模板来整体使用这些容器呢?
这就需要一些辅助的技术来控制,既要达到在上层的接口统一性,又要保证在调用下层的库接口时的安全。这时,就用到了模板的SFINAE技术,当然,在高版本中,也可以使用Concepts(概念)。
四、实例
通过上面的分析,基本明白了这个抽象的数据结构封装的方式,其实就是在STL库的上层再封装一层,同时要保证多线程访问时的安全性,看下面的例子:
1、首先对原TaskQueue进行改造:
c
#ifndef __TASKBUCKET_H__
#define __TASKBUCKET_H__
#include "common.h"
#include <iostream>
#include <mutex>
#include <queue>
template <typename T, typename Con = std::queue<T>> class TaskBucket final : public NoCopy {
public:
TaskBucket() {}
~TaskBucket() {}
public:
void Pop() { this->queue_.pop(); }
void Push(T t) {
std::cout << "Push bool:" << HasMemFuncCpp11<Con>::value << std::endl;
std::unique_lock lock(this->mu_);
if constexpr (HasMemFuncCpp11<Con>::value) {
this->queue_.emplace_back(t);
}
}
T Front() {
std::unique_lock lock(this->mu_);
return this->queue_.front();
}
T Back() {
std::unique_lock lock(this->mu_);
return this->queue_.back();
}
auto PopFront() {
std::cout << "PopFront bool:" << HasMemFunc<Con>::value << std::endl;
std::unique_lock lock(this->mu_);
if constexpr (HasMemFunc<Con>::value) {
T t = this->queue_.front();
this->queue_.pop_front();
return t;
}
}
T PopBack() {
std::cout << "PopBack bool:" << has_member_pop_back<Con>::value << std::endl;
if constexpr (hasmem_pop_back<Con>::value) {
std::unique_lock lock(this->mu_);
T t = this->queue_.back();
this->queue_.pop_back();
return t;
}
}
private:
std::mutex mu_;
Con queue_;
};
上面的代码主动对三部分进行了处理,一部分是增加了类本身不允许拷贝;另外一个增加了对容器的再次抽象,即容器可以动态传入;第三部分就是增加了对容器的成员函数通过SFINAE进行了判断。这也是模板技术在实际场景中应用的一种方式,下面会对其进行分析。需要注意的是,这里使用的c++17以上的编译环境。下面看一下相关的代码:
c
#ifndef __COMMON_H__
#define __COMMON_H__
#include <functional>
#include <utility>
using CallBackMsg = std::function<void(int *, int)>;
using Task = std::function<void(int)>;
class NoCopy {
protected:
NoCopy() = default;
~NoCopy() = default;
public:
NoCopy(const NoCopy &) = delete;
NoCopy &operator=(const NoCopy &) = delete;
NoCopy(NoCopy &&) = delete;
NoCopy &operator=(NoCopy &&) = delete;
};
//第一种方式
template <typename T> struct HasMemFunc {
private:
template <typename U> static auto Check(int) -> decltype(std::declval<U>().pop_front(), std::true_type());
template <typename U> static std::false_type Check(...);
public:
enum { value = std::is_same<decltype(Check<T>(0)), std::true_type>::value };
};
//Muduo库的使用方式(和上面类似):
template <typename T> struct hasPopFront {
template <typename C> static char Check(decltype(&C::pop_front));
template <typename C> static int32_t Check(...);
const static bool value = sizeof(Check<T>(0)) == 1;
};
//第二种方式
template <typename T> using Type = void;
//处理无参
template <typename T, typename V = void> struct HasPop : std::false_type {};
template <typename T> struct HasPop<T, Type<decltype(std::declval<T>().pop_front())>> : std::true_type {};
//第二种方式-c++11后
template <typename T, typename V = void> struct HasMemFuncCpp11 : std::false_type {};
template <typename T>
struct HasMemFuncCpp11<T, Type<decltype(std::declval<T>().emplace_back(std::declval<typename T::value_type>()))>>
: std::true_type {};
//第三种方式
#define HAS_MEM(mem) \
template <typename T, typename... Args> struct hasmem_##mem { \
private: \
template <typename U> \
static auto Check(int) -> decltype(std::declval<U>().mem(std::declval<Args>()...), std::true_type()); \
template <typename U> static std::false_type Check(...); \
\
public: \
enum { value = std::is_same<decltype(Check<T>(0)), std::true_type>::value }; \
};
HAS_MEM(pop)
HAS_MEM(pop_back)
HAS_MEM(push)
#endif // __COMMON_H__
在上面代码中主要分成了三种形式,第一种和第二种是使用检查函数是否存在的普通方式,虽然看上去是使用了模板,但对成员函数名称还是进行了限制。所以如果需要限制多种成员函数还得手动写多个类;第三种使用宏的方式自动产生相关的模板代码,这样会少写不少的重复的类似的代码。
这三种SFINAE的原理基本是一致的,虽然实现的手段有细节的不同。但都是通过decltype和std::declval(二者的区别可以查看前面的相关文章),通过是否能够符合模板自动匹配(即能否正常产生相关特化模板)来返回true 或false的value来达到判断是否存在函数的目的,然后通过if constexpr在编译期进行处理。Muduo库的使用方式前面分析过("SFINAE的技巧应用"),基本原理也是如此,只是直接使用定义的函数匹配来得到值而非使用std::true_type这种形式,但逻辑是一样的。
至于是否使用 std::is_same_v来替代 std::is_same,就看个人的兴趣了。
大家分析的时候儿可以从第一种开始,简单的按分析说明一对就明白了,然后再到第二种,特别是Type类型能否获取,是value值的关键。到第三种基本就是前面的抽象版,理解了前面的两种方式,第三种就非常简单了。
此处的封装未必是要求把所有的容器都封装起来,但它可以适应所有容器,什么意思呢?开发者可以根据自己的情况来选取指定的一两种容器来使用。当然,在实际的应用场景中,可能更多的是直接使用容器。这里之所以封装起来,就是为了给出一个更搞抽象的思路和方法。这点对于自己写容器而不使用STL中的容器的情况下更有意义。
随着GCC等编译器对c++20支持的逐渐普及化。估计明后年,可能就可以普遍的使用c++20编程了(ubuntu22默认的g++11对c++20已经支持了大部分),这样就可以使用Concept以及其它相关的新特性,开发工作会变得更简单一些。
五、总结
在分析完成数据结构的封装后,基本也就明白了在多线程中如何使用STL中的容器。但是,一般对线程安全的操作控制,都是使用锁。不管锁的粒度是大是小,对性能的影响一般来说都是比较大的。而多线程特别是线程池恰恰又都是在高性能的场景下应用,所以这时就需要开发者认真考虑加锁引起的性能损失。
当然,在后面可以考虑在某些情况下使用无锁编程技术,让数据的处理更快捷。但无锁编程也不是万能的,它也是有其相对的应用场景。万流归宗,还是需要开发者对整体技术的把握和实际应用场景进行综合考虑。