记得大学刚毕业那年看了侯俊杰的《深入浅出MFC》,就对深入浅出这四个字特别偏好,并且成为了自己对技术的要求标准——对于技术的理解要足够的深刻以至于可以用很浅显的道理给别人讲明白。以下内容为个人见解,如有雷同,纯属巧合,如有错误,烦请指正。
在说入侵式容器前,先说一说什么是容器,本文提到的容器与docker一点关系都没有,是C++标准库中std::map、std::set、std::list等用来存放数据对象的这些类。在C++中,采用模板实现容器内对象的分类,也就是说通过模板实例化出具体类型对象的容器。
了解了容器之后,再说说入侵式容器,其实这里的入侵式(intrusive )是非常形象的形容词,我们可以通过如下两张链表图的对比来看:
图1 图2
我们暂时不要计较两个图的链表是不是需要形成环,我们就假设有一个头指针(head)和尾指针(tail)分别指向了链表的第一个节点和最后一个节点就可以了。上面两个图是非常经典的双向链表管理对象的实现方式,他们两的不同在于链表本身(或链表的一部分内容)是否在存储对象内。图1链表通过一个指针指向对象;图2是把链表放在对象内,再将链表连接起来达到对象的双向连接,我个人把图2所示的双向链表称之为入侵式双向链表,原因就是链表入侵到了对象内部。
现在我们可以对比一下两种方式各有什么优缺点:
-
从图上很明显的对比,入侵式双向链表由于省略了obj指针,所以要更节省内存,但这点对于犹如白菜价内存的今天没什么优势可言,当然管理对象的越小、数量越多优势会明显一些;
- 在遍历链表并定位具体对象时,非入侵式双向链表由于有指向对象的指针可以直接获取对象,而入侵式双向链表需要通过链表地址减去偏移才能得到对象指针,但这点计算量基本可以忽略;
- 非入侵式双向链表的obj指针可以采用无类型指针(void*),这样链表可以连接多种对象(通过指针第一个地址的类型在区分对象),但是这种应用还是比较不多见;同时如果对比std::list和boost::intrusive::list两个链表的话,由于对象类型已经通过模板确定,也就不存在这个优点了(模板类型void*除外哈,怎么感觉除外的内容这么多~);
- 入侵式指针如果拥有对象指针,就可以将对象本身从链表中删除,而非入侵式指针由于对象没有指向链表的指针,需要遍历链表才能删除对象;
其他的也看不出来有什么有优缺点,那为什么还要实现这两种链表呢?道理其实很简单,看你主要操作的对象是什么,也就是使用场景:
- 非入侵式双向链表:操作的对象主要是链表本身,对于链表管理对象本身并不太多关注,此类应用主要为数据库类型。以redis为例,redis底层数据结构之一的链表为例,如下是4.0.10\adlist.h的源码:
typedef struct listNode {
struct listNode *prev;
struct listNode *next;
void *value;
} listNode;
如上源码所示,redis的链表通过无类型指针指向对象。数据库类型应用主要关注的是数据的有效管理,前期不知道所要管理的对象,也就无从谈起入侵式容器了。
- 入侵式双向链表:操作的主要以目标主要是对象,仅仅想通过一种方式(如链表)将多个对象有效的管理起来。由于入侵式双向链表可以把自己从一个链表中删除,所以应用中只要传递对象本身即可,无需再传入迭代器(iterator,如果对迭代器不理解的需要先学习一下基础知识);
其实我们正常编写的程序应该主要使用入侵式双向链表,因为我们主要操作的都是我们自定义的对象(例如在linux内核(/include/linux/list.h)中使用非常广泛),那为什么还有很多人不用入侵式双向链表甚至还有人没听说过呢(采用c++ stl编程的人居多,写c代码的人更倾向于使用list_head)?原因在于应用本身对于对象管理的复杂度不高,非入侵式代码可读性更高,实现复杂度更低(主要集中在c++ stl容器类)。
上文主要以双向链表为例说明了入侵式与非入侵式容器的差别,容器的类型还有很多:vector、list、set、map、multiset、multimap,此处就不一一列举了,毕竟不是本文讨论的重点。上面是以c的视角分析了二者的区别,接下来我们以c++的stl视角分析二者的区别。还是一样的,我们以一种容器为例,这回我们用map。
我们以分布式计算系统为例,中心需要管理多个节点,每个节点有唯一的名字,那么采用如下定义实现节点管理:
std::map<std::string, std::shared_ptr<Node>>,其中Node为自定义的节点类型。
这样就可以在各个子模块间传递节点名称唯一标识一个节点了。那么接下来需求来了,仅仅通过名字管理节点是无法满足需求的,中心需要知道每个节点的资源负载率,在下次调度计算请求的时候尽量调度到资源负载率最低的节点。对于这个需求,我们有两个实现方案:
- 遍历上面提到的map,找到负载率最低的节点;
- std::map<Load, std::shared_ptr<Node>>,其中Load为节点负载率类型,中心实时根据节点的负载率进行排序,第一个节点就是负载率最小的节点;
我们来分析两个方案,第一个方案的缺点是需要遍历所有节点,计算量为O(n),但是当节点更新负载率的时候无需任何操作;方案二在查找负载率最低的节点时计算量为O(1),但是节点更新负载率时需要重新调整位置。由于std::map采用树(引用源码class map: public _Tree<_Tmap_traits<_Kty, _Ty, _Pr, _Alloc, false> >)方式管理数据,可以假设计算量为O(log(n))。
我跟人认为方案二相对较好,同时也是我在做分布式计算系统时选择的方案(当初还不知道C++有入侵式容器,后来改用list_head方式自行设计排序,所以每次更新负载率时排序计算量最悲观是O(n))。但是方案对于我来说有一个件事情不能忍,那就是每次更新节点负载率时都要备份以前的负载率,这样才能更新负载率的map(先删除再添加)。过分考虑异常的我总是担心节点中记录的负载率由于某些BUG被修改,这将导致节点在负载率的map无法更新,这是一个非常不安全的事情。在拥有节点指针的情况下从负载率map中删除如果是一个可靠的动作,重新插入的时候依赖的计算的负载率,这样即便负载率存在BUG,只是影响排序问题,问题没那么严重。
此时,入侵式容器发挥价值的时候到了,他完美的解决了我提出的问题。首先,我们需要在Node类型定义中加入容器成员的钩子,如下所示:
class Node
{
......
private:
boost::intrusive::set_member_hook<> m_resLoadHook;
......
}
注:本文入侵式容器采用boost1.61.0版本。
现在是我自有发挥的时候了(也是正经的胡说时间)!set_member_hook,每个单词都已特定的意义:
set:代表是容器类型是set(为什么是set而不是map,后面会说)
member:代表要成为所管理对象的成员,如上面定义的Node一样加入一个该类型的成员变量
hook:这个是钩子,就是把对象通过钩子挂在容器内,有点像windows的钩子函数,把一段代码挂在系统内;
那么我们研究的重点就是这个钩子到底是个啥?接下来就是比较恶心的boost模板代码旅程了,先看boost::intrusive::set_member_hook的定义:
#if defined(BOOST_INTRUSIVE_DOXYGEN_INVOKED) || defined(BOOST_INTRUSIVE_VARIADIC_TEMPLATES)
template<class ...Options>
#else
template<class O1, class O2, class O3, class O4>
#endif
class set_base_hook
: public make_set_base_hook<
#if !defined(BOOST_INTRUSIVE_VARIADIC_TEMPLATES)
O1, O2, O3, O4
#else
Options...
#endif
>::type
{
......
}
因为有宏定义BOOST_INTRUSIVE_VARIADIC_TEMPLATES让代码看着有点乱,我们可以假设这个宏是打开的,那么代码就会简洁很多,如下所示:
template<class... Options> class set_member_hook
: public make_set_base_hook<Options...>::type
上面代码可以看到set_member_hook继承自make_set_base_hook<Options...>::type,那make_set_base_hook::type又是什么呢?
template<class ...Options>
struct make_set_base_hook
{
typedef typename pack_options < hook_defaults, Options... >::type packed_options;
typedef generic_hook
< rbtree_algorithms<rbtree_node_traits<typename packed_options::void_pointer, packed_options::optimize_size> >
, typename packed_options::tag
, packed_options::link_mode
, RbTreeBaseHookId
> implementation_defined;
typedef implementation_defined type;
};
make_set_base_hook又把Options选项类加上hook_defaults合并为一个新的packed_options类型。嗯!名字比较形象。make_set_base_hook::type是generic_hook::type加上一大堆模板的类型重定义,所以还要看generic_hook是怎么定义的?
template
< class NodeAlgorithms
, class Tag
, link_mode_type LinkMode
, base_hook_type BaseHookType
>
class generic_hook
: public detail::if_c
< detail::is_same<Tag, member_tag>::value
, typename NodeAlgorithms::node
, node_holder<typename NodeAlgorithms::node, Tag, BaseHookType>
>::type
, public hook_tags_definer
< generic_hook<NodeAlgorithms, Tag, LinkMode, BaseHookType>
, detail::is_same<Tag, dft_tag>::value*BaseHookType>
{
......
}
看到上面代码还没有崩溃的算是能抗的(我不是天生好强,只是我比较能抗),不佩服写boost的大神都不行,这模板用的真6,6到基本看不懂。我们从代码是能够发现内容,generic_hook是继承自两个类型:
detail::if_c
< detail::is_same<Tag, member_tag>::value
, typename NodeAlgorithms::node
, node_holder<typename NodeAlgorithms::node, Tag, BaseHookType>
>::type
hook_tags_definer
< generic_hook<NodeAlgorithms, Tag, LinkMode, BaseHookType>
, detail::is_same<Tag, dft_tag>::value*BaseHookType>
我们先从第一个类型分析:
template<bool C, typename T1, typename T2>
struct if_c
{
typedef T1 type;
};
上面的代码可以看出detail::if_c<#$$%%^%^&>::type=NodeAlgorithms::node(至于为啥要绕这么大圈我们在其他的文章在说明哈),而NodeAlgorithms是构造generic_hook的模板,终于看到点曙光了~好了,我们再回头看一下代码:
struct make_set_base_hook
{
typedef typename pack_options < hook_defaults, Options... >::type packed_options;
typedef generic_hook
< rbtree_algorithms<rbtree_node_traits<typename packed_options::void_pointer, packed_options::optimize_size> >
, typename packed_options::tag
, packed_options::link_mode
, RbTreeBaseHookId
> implementation_defined;
typedef implementation_defined type;
};
NodeAlgorithms= rbtree_algorithms<rbtree_node_traits<typename packed_options::void_pointer, packed_options::optimize_size> >,顺着思路我们可以继续探索rbtree_algorithms<@#$%&>::node究竟为何方神圣:
template<class NodeTraits>
class rbtree_algorithms
#ifndef BOOST_INTRUSIVE_DOXYGEN_INVOKED
: public bstree_algorithms<NodeTraits>
#endif
{
public:
typedef NodeTraits node_traits;
typedef typename NodeTraits::node node;
typedef typename NodeTraits::node_ptr node_ptr;
typedef typename NodeTraits::const_node_ptr const_node_ptr;
typedef typename NodeTraits::color color;
......
}
rbtree_algorithms<@#$%&>::node是NodeTraits::node的重定义,再返回上面的代码可知传入的模板为:
rbtree_node_traits<typename packed_options::void_pointer, packed_options::optimize_size>
那就在看看rbtree_node_traits是怎么定义的吧?
template<class VoidPointer, bool OptimizeSize = false>
struct rbtree_node_traits
: public rbtree_node_traits_dispatch
< VoidPointer
, OptimizeSize &&
(max_pointer_plus_bits
< VoidPointer
, detail::alignment_of<compact_rbtree_node<VoidPointer> >::value
>::value >= 1)
>
{};
又是一大堆~我们直接奔主要内容,rbtree_node_traits继承自rbtree_node_traits_dispatch:
struct rbtree_node_traits_dispatch
: public default_rbtree_node_traits_impl<VoidPointer>
{};
rbtree_node_traits_dispatch又继承自default_rbtree_node_traits_impl:
template<class VoidPointer>
struct default_rbtree_node_traits_impl
{
typedef rbtree_node<VoidPointer> node;
typedef typename node::node_ptr node_ptr;
typedef typename node::const_node_ptr const_node_ptr;
typedef typename node::color color;
.......
}
终于找到了这个真正的node的定义了,他是rbtree_node<VoidPointer>的重定义:
template<class VoidPointer>
struct rbtree_node
{
typedef rbtree_node<VoidPointer> node;
typedef typename pointer_rebind<VoidPointer, node >::type node_ptr;
typedef typename pointer_rebind<VoidPointer, const node >::type const_node_ptr;
enum color { red_t, black_t };
node_ptr parent_, left_, right_;
color color_;
};
看到了么?rbtree_node有父节点、左节点、右节点的指针以及当前节点的颜色,同时将VoidPointer和rbtree_node的绑定。从中我们可以得到以下几个信息:
- 每个节点是一个红黑树节点,至于什么是红黑树自己去百度吧(我发现只要不知道的就可以说不是此处讨论重点,自行去百度~)
- 每个节点包含一个void*类型的指针,这个可以从VoidPointer名称得知,严谨的可以沿着模板继承路线找到哈,这个指针就是容器管理对象的指针;
那么,我们之前定义的计算节点类型就可以看做为(因为我们没有传递任何选项,所有的就是默认选项,读者可以自行了解每个选项是什么意思):
class Node
{
......
private:
rbtree_node m_resLoadHook;
......
}
大概的图形如下所示,仅仅是示意,没有画出全部内容:
上图和我们开篇说的入侵式双向链表非常像,在拥有对象指针时,将对象从容器中删除不再需要通过key查找了,把钩子从容器中移除就可以了。上面说了好多代码,这仅仅是开始,我们还有好多问题没有解答:容器怎么定义?容器内节点如何排序?我们先来看看入侵式set如何定义的:
template<class T, class ...Options>
class set
: public make_set<T,
Options...
>::type
{
......
}
set类继承自make_set::type:
template<class T, class ...Options>
struct make_set
{
typedef typename pack_options
< rbtree_defaults,
Options...
>::type packed_options;
typedef typename detail::get_value_traits
<T, typename packed_options::proto_value_traits>::type value_traits;
typedef set_impl
< value_traits
, typename packed_options::key_of_value
, typename packed_options::compare
, typename packed_options::size_type
, packed_options::constant_time_size
, typename packed_options::header_holder_type
> implementation_defined;
typedef implementation_defined type;
};
上面的代码可以看出,make_set::type=set_impl<$$%%^&*&*>:
#if defined(BOOST_INTRUSIVE_DOXYGEN_INVOKED)
template<class T, class ...Options>
#else
template<class ValueTraits, class VoidOrKeyOfValue, class Compare, class SizeType, bool ConstantTimeSize, typename HeaderHolder>
#endif
class set_impl
#ifndef BOOST_INTRUSIVE_DOXYGEN_INVOKED
: public bstree_impl<ValueTraits, VoidOrKeyOfValue, Compare, SizeType, ConstantTimeSize, RbTreeAlgorithms, HeaderHolder>
#endif
{
......
}
上面的代码可以看出无论宏定义BOOST_INTRUSIVE_DOXYGEN_INVOKED是否打开,set_impl都需要ValueTraits,VoidOrKeyOfValue,Compare,SizeType,ConstantTimeSize,HeaderHolder这几个类作为模板参数构造类型。在回头看看make_set中对于set_impl传入的模块:
ValueTraits:detail::get_value_traits<T, typename packed_options::proto_value_traits>::type
VoidOrKeyOfValue: packed_options::key_of_value
Compare:packed_options::compare
SizeType:packed_options::size_type
ConstantTimeSize:packed_options::constant_time_size
HeaderHolder:packed_options::header_holder_type
其中packed_options是我们传入的选项类加上红黑树的默认选项组合而成,如代码所示:
typedef typename pack_options
< rbtree_defaults,
Options...
>::type packed_options;
ValueTraits是我们传入的容器管理对象的类型加上一些选项而成,此处不细说这个是怎么来的,至少我们知道构造入侵式容器需要提供管理对象的类型,这个和std::set一样。关键的是这些选项类我们应该传入什么?而且我们前期自己定义的Node类型中的钩子怎么使用?我们再来看看set_impl模板参数:
- VoidOrKeyOfValue:字面上意思是空或者值的key,这个有点意思哈!我们不管空,值的key作为容器本身自己不知道么?还要我们传递一个选项进来?我们假定set的实现是利用红黑树(前面已经好多地方提到了红黑树~),对于一棵树管理对象的key应该是什么?其实就是rbtree_node这个拥有指向对象的东东,那么我们就联想到我们以前定义那个钩子的用处了,需要把它传入到这里,嗯,应该是这么个意思~
- Compare:比较函数,set毕竟是有序的,比较函数还是很有必要的,容器需要知道怎么比较对象,同时也可以自定比较函数;
其他的选项我就不细说了,读者可以自行了解,因为以上的内容就够我用啦~~~~现在我们就看看怎么定义这个入侵式set:
using boost::intrusive
typedef member_hook<Node,set_member_hook<>, &Node::m_resLoadHook> HookOption;
typedef set<Node, HookOption,compare<CompareResLoad>> ResLoadSet;
ResLoadSet就是我们定义出来的管理Node对象的集合,其中HookOption要告诉容器值的key用的是set_member_hook<>类型,叫m_resLoadHook这个名字(采用&Node::m_resLoadHook告诉key在对象的地址偏移的方式),最后就是排序函数,自己按照函数标准实现就可以了。至此我们就可以使用ResLoadSet来管理Node,这个容器里的每个节点都是按照负载率排好序的。使用入侵式容器必须要注意一下几点:
- 容器本身只需要把对象的钩子通过比较函数挂到指定位置就行了,容器操作的是对象的钩子成员变量,使用者需要自行管理对象本身内存。这一点与非入侵式容器不同,非入侵式容器是在内部管理对象内存的。这就要求不能向入侵式容器中插入局部变量,也就意味着所有的对象都是new出来的指针(全局变量也可以,如果所有对象都能够用全局变量定义了,那还用容器干啥呢?)
- 我们操作对象都是通过指针的,因为是new出来的,但是入侵式容器各种接口函数要求传入对象的引用~这一点我只能认为实现者考虑我们使用非入侵式容器需要传入对象引用的习惯,还是不错的,只是知道原理后感觉怪怪的。
现在我来解释一下为什么前面用map,到了入侵式容器我们选择了set,如果我说入侵式容器没有map你会不会打我?其实我们细想想确实入侵式map没有存在的可能,原因如下:
- map<Key,Node>,key定义在其他对象内,入侵式也就无从谈起;
- 如果把Key定义在Node里面,那不就是入侵式set么?
其实入侵式容器应用在一个对象需要通过多个容器进行管理的时候非常好用,比如Node分为CPU、内存、GPU、网络等几个负载率。当需要将节点删除时,由于Node内有所有容器的钩子,删除非常方便,但是如果是通用的map,很有可能存在获取相应map的key值比较麻烦的情况。这一点linux内核用list_head管理各种对象,对象从一种状态切换到另一种状态,就需要将对象从若干链表移除放入其他链表,task管理就是一个比较典型的例子。
文章最后,我们来看下boost官方文档是如何介绍入侵式容器的:
Boost.Intrusive is a library presenting some intrusive containers to the world of C++. Intrusive containers are special containers that offer better performance and exception safety guarantees than non-intrusive containers (like STL containers).
The performance benefits of intrusive containers makes them ideal as a building block to efficiently construct complex containers like multi-index containers or to design high performance code like memory allocation algorithms.
While intrusive containers were and are widely used in C, they became more and more forgotten in C++ due to the presence of the standard containers which don't support intrusive techniques.Boost.Intrusive wants to push intrusive containers usage encapsulating the implementation in STL-like interfaces. Hence anyone familiar with standard containers can easily use Boost.Intrusive.
大概意思是:入侵式容器相比于非入侵式容器拥有更好的性能和异常安全保证,入侵式容器的高性能广泛应用在多索引容器或者类似内存分配算法这样的高性能程序上。入侵式容器在C程序中比较普遍,人们渐渐在C++中忘记入侵式容器是因为标准库中不支持入侵式容器......后面的就不废话了。最重要的一点是入侵式容器好用并且性能高,为什么性能好?我认为有以下几点:
- 管理对象的内存是用户自己管理的,容器只负责对象内的钩子成员变量的操作,省去了对象内部管理的部分,也就省去了对象拷贝的过程。当然,如果容器的模板传入的是对象指针类型(如set<Node*>)就另当别论了;
- 删除对象的时候复杂度为常数,如O(1),而非入侵式容器复杂度是O(log(n))或O(n);
- 查找性能与删除原理一样,所以就不多说了;