Boost.ASIO源码:thread_info_base::allocate古怪的内存分配逻辑解析与猜想

自我感觉这个内存组织思路很有参考学习价值,故在此总结一下。

thread_info_base介绍

啥是thread_info

要引出thread_info_base就得从scheduler说起,scheduler实际上就是io_service的一个实现,而众所周知(不知者百度),io_service中有一个公共队列和若干个与线程数对应的私有队列,当用户post了数据进来时,随机的某个空闲线程的私有队列就会push进这个请求,然后该线程就会开始处理这个请求。这时候再回归问题,thread_info保存的就是某个线程下的私有队列以及队列中的未完成工作数:

struct scheduler_thread_info : public thread_info_base
{
  op_queue<scheduler_operation> private_op_queue;   // 工作队列
  long private_outstanding_work;  // 未完成的工作数。(这个outstanding我刚开始还理解的是“杰出的”的意思。。。实际是“未完成的”)
};

这个scheduler_thread_info就是实际所用的实现类,接下来看看它的基类thread_info_base

thread_info_base概览

直接上源码:

class thread_info_base
  : private noncopyable
{
public:
  thread_info_base()
    : reusable_memory_(0) {}

  ~thread_info_base()
  {
    if (reusable_memory_)
      ::operator delete(reusable_memory_);
  }

  static void* allocate(thread_info_base* this_thread, std::size_t size)
  {
    // 一大坨代码
  }

  static void deallocate(thread_info_base* this_thread,
      void* pointer, std::size_t size)
  {
    // another 一大坨代码
  }

private:
  enum { chunk_size = 4 };   // 分配内存的最小单位为32位
  void* reusable_memory_;   // 一块可重用内存的首地址
};

这个类实际上并没有什么信息,除了继承自noncopyable表明它是不可复制的,剩下的就全都与内存分配相关了。
需要稍稍注意的是 那个只有一个元素的内部枚举类,这种写法实际上只是为了得到一个编译器常量罢了,这里并没有任何语义上的枚举用法,写成constexpr chunk_size = 4或者Integral_constant<int, 4> chunk_size应该也是一样的。

thread_info的内存分配逻辑细节

allocate与deallocate

先上allocate和deallocate的源码,我特地加上了注释方便理解:

static void* allocate(thread_info_base* this_thread, std::size_t size /* 需要分配的字节数 */)
  { // 注意这里chunk没有明显的语义,它就是个最小存储单位,即4字节
    std::size_t chunks = (size + chunk_size - 1) / chunk_size;  // 由字节数得到需要分配的chunk数,注意向上取整

    if (this_thread && this_thread->reusable_memory_)//判断传进来的this_thread是不是空指针且它内部所指的那块可重用内存空间是不是空的
    { // 如果有现有的可重用内存空间,就先判断现有的内存空间够不够大,如果足够塞下那就直接返回,不够的话就重新申请内存
      void* const pointer = this_thread->reusable_memory_; // 把这块我们需要操作的内存空间暂存下来
      this_thread->reusable_memory_ = 0;  // 先把它置空晾一边

      unsigned char* const mem = static_cast<unsigned char*>(pointer);  // 转成char数组方便指针运算
      //这里是个小优化,后面讲。只要知道mem[0]内存的是这块内存的chunk数目大小就行了
      if (static_cast<std::size_t>(mem[0]) >= chunks)  // 如果这块内存空间的chunk足够多,那就直接返回
      {
      	// 把chunk数目大小存在这块内存空间的最后面
      	// (注意这里不会内存越界,因为实际上总会比记录的chunk多分配一个字节)
      	// (还有人会问一个字节就够存吗?再回到上面看一下,我们实际上只用了一个字节来表示chunk数量,mem[0]取到的只有一个字节,毕竟它只是char,不是int)
        mem[size] = mem[0];
        return pointer;
      }
		// 否则若这块内存不够我们想要分配的量,那就删掉这块内存重新分配
      ::operator delete(pointer);
    }
	//以下是重新分配内存逻辑
    void* const pointer = ::operator new(chunks * chunk_size + 1); // 注意此处多分配了一个字节
    unsigned char* const mem = static_cast<unsigned char*>(pointer);
    // UCHAR_MAX代表'unsigned char'可以保存的最大值(因为我们的chunk数相当于是用一个unsigned char来保存的呀)
    mem[size] = (chunks <= UCHAR_MAX) ? static_cast<unsigned char>(chunks) : 0;  // 把chunk数存到尾端
    return pointer;
  }

  static void deallocate(thread_info_base* this_thread,
      void* pointer, std::size_t size)  // pointer所指的内存才是要释放的内存
  {
    if (size <= chunk_size * UCHAR_MAX)  // 这块内存的chunk数是不是超出了一个字节所能表示的极限
    {
      if (this_thread && this_thread->reusable_memory_ == 0)
      {
        unsigned char* const mem = static_cast<unsigned char*>(pointer);
        mem[0] = mem[size];  // 把存在尾端的chunk数重新存到第一个字节
        this_thread->reusable_memory_ = pointer;
        return;
      }
    }
    ::operator delete(pointer);
  }

细节逻辑在上面代码中的注释已经讲得很详细了,这里总结一下allocate和deallocate的逻辑思路。先声明,allocate和deallocate都是最底层函数,最外界实际上是不会传入thread_info_base参数的,这个参数来自另一个thread_info栈,等会再讲。
deallocate实际上先判断pointer所指的这块要释放的内存是不是太大了,如果太大了就直接删掉,否则就重新组织一下并把它放到可重用内存块中方便下次分配内存时使用;而allocate则是取出这块可重用内存块(若有的话),足够大就直接返回,否则就重新申请块内存空间再返回。其中涉及到的关于可重用内存块的chunk数的维护这里不细讲了,不懂者多看两次上面的注释也就懂了。

自动传入的thread_info_base

直接看调用处:

void* asio_handler_allocate(std::size_t size, ...)
{
  return detail::thread_info_base::allocate(
      detail::thread_context::thread_call_stack::top(), size);
}
void asio_handler_deallocate(void* pointer, std::size_t size, ...)
{
  detail::thread_info_base::deallocate(
      detail::thread_context::thread_call_stack::top(), pointer, size);
}

可以看到都是自动把一个栈的最上层元素给传入。这个thread_call_stack实际上是call_stack<thread_context, thread_info_base>的别名:

class thread_context
{
public:
  // Per-thread call stack to track the state of each thread in the context.
  typedef call_stack<thread_context, thread_info_base> thread_call_stack;
};

为了方便后面理解,这里先提前剧透:这个stack实际上就是个单链表,每个节点保存有键值对,键就是thread_context,值就是thread_info_base。
再小小提点一下,诸如scheduler这种类也是继承自thread_context的,实际上就是为了能在这个栈中通过scheduler找到它的scheduler_thread_info(这是个实现类)。
再看call_stack,这里面大部分的逻辑都只是维护栈结构用的,关系不大,但是为了代码完整性我还是全部贴出来了,以防有人像我一样看不到完整代码就不舒服斯基,重点只用关注最下面的top_静态成员就行了:

// Helper class to determine whether or not the current thread is inside an
// invocation of io_context::run() for a specified io_context object.
template <typename Key, typename Value = unsigned char>
class call_stack
{
public:
  // Context class automatically pushes the key/value pair on to the stack.
  class context
    : private noncopyable
  {
  public:
    // Push the key on to the stack.
    explicit context(Key* k)
      : key_(k),
        next_(call_stack<Key, Value>::top_)
    {
      value_ = reinterpret_cast<unsigned char*>(this);
      call_stack<Key, Value>::top_ = this;
    }

    // Push the key/value pair on to the stack.
    context(Key* k, Value& v)
      : key_(k),
        value_(&v),
        next_(call_stack<Key, Value>::top_)
    {
      call_stack<Key, Value>::top_ = this;
    }

    // Pop the key/value pair from the stack.
    ~context()
    {
      call_stack<Key, Value>::top_ = next_;
    }

    // Find the next context with the same key.
    Value* next_by_key() const
    {
      context* elem = next_;
      while (elem)
      {
        if (elem->key_ == key_)
          return elem->value_;
        elem = elem->next_;
      }
      return 0;
    }

  private:
    friend class call_stack<Key, Value>;

    // The key associated with the context.
    Key* key_;

    // The value associated with the context.
    Value* value_;

    // The next element in the stack.
    context* next_;
  };

  friend class context;

  // Determine whether the specified owner is on the stack. Returns address of
  // key if present, 0 otherwise.
  static Value* contains(Key* k)
  {
    context* elem = top_;
    while (elem)
    {
      if (elem->key_ == k)
        return elem->value_;
      elem = elem->next_;
    }
    return 0;
  }

  // Obtain the value at the top of the stack.
  static Value* top()
  {
    context* elem = top_;
    return elem ? elem->value_ : 0;
  }

private:
  // The top of the stack of calls for the current thread.
  static tss_ptr<context> top_;
};

总结下:context有点类似pair,只不过构造context时会自动把这个context放到栈顶,栈顶由top_维护,是全局的。tss_ptr就是个普通指针类型,跟普通指针的唯二区别就是继承自noncopyable以及它的值是保存在一个静态变量里的

为何要自动传入栈顶的thread_info_base

先拉个免责声明,以下内容没有得到完整的源码证明,或者说源码没完全看明白(因为找不到最外外外面是怎么调用allocate的。。),一部分来源于第六感猜测,仅作参考,不要把以下理论当成标准。当然,若有人知道详情欢迎打脸。

前面已经知道了asio_handler_allocate调用了最底层的thread_info_base_allocate(deallocate同理),再往外找可以看到:

namespace boost_asio_handler_alloc_helpers {
	template <typename Handler>
	inline void* allocate(std::size_t s, Handler& h)
	{
	  using boost::asio::asio_handler_allocate;
	  return asio_handler_allocate(s, boost::asio::detail::addressof(h));
	}
	
	// 。。。。。
}

可以看到这里用了一个模板函数allocate来调用asio_handler_allocate,这里传入的第二个参数在下面并没有得到处理,我也看不懂这个是用来干吗的。再往外找(这里只列一个例子,还有很多类似的调用):

template <typename Handler, typename Arg1, typename Arg2,
    typename Arg3, typename Arg4, typename Arg5>
inline void* asio_handler_allocate(std::size_t size,
    binder5<Handler, Arg1, Arg2, Arg3, Arg4, Arg5>* this_handler)
{
  return boost_asio_handler_alloc_helpers::allocate(
      size, this_handler->handler_);
}

这里能确定的是传入的handler是一个伪函数,binder5只是给某个函数对象绑上5个固定参数,与std::bind同理,而这个伪函数对象传进allocate函数之后其实啥都不干(就是耍流氓)。

好了,以下来自我的推理:
首先,为什么要给top分配大小是这个伪函数的大小(这个很可能是仅用一个字节保存chunk数的原因)。说明这个栈顶的thread_info_base是用来存这个伪函数对象的,再推导一下,很可能所有的thread_info_base中的可重用内存空间都是给这些伪函数对象用的,因为这样才有重用的价值——因为伪函数对象的大小差别不会太大,这样造成的内部碎片才不会太大。
接下来再看下另外一个用到thread_info的地方:

std::size_t scheduler::run(boost::system::error_code& ec)
{
  ec = boost::system::error_code();
  if (outstanding_work_ == 0)
  {
    stop();
    return 0;
  }

  thread_info this_thread;   // 注意这里声明的临时变量,并初始化
  this_thread.private_outstanding_work = 0;
  thread_call_stack::context ctx(this, this_thread);  // 还记得scheduler是继承自thread_context的么

  mutex::scoped_lock lock(mutex_);

  std::size_t n = 0;
  for (; do_run_one(lock, this_thread, ec); lock.lock())
    if (n != (std::numeric_limits<std::size_t>::max)())
      ++n;
  return n;
}

这相当于io_service的run,在这里面会初始化一个thread_info,此时它就在栈顶了,注意马上就上锁了所以能保证同时间后续的处理逻辑中,栈顶的那个thread_info一直保持不变(哪怕是由于多个run同时触发,2个同时走完this_thread的创建,而实际拿到锁的那个run的后续处理用的实际不是自己创建的那个thread_info也没关系,反正都一样,只要始终如一就行)。然后后续的处理逻辑,也就是do_run_one中,很可能会创建前面提到的那些handler(伪函数),它们的内存空间应该是来自这块可重用内存,而线程可能会重复多次do_run_one,也就是说会用到多个handler,如此重复的分配内存回收内存用上可重用内存的逻辑应该就能带来很大的效率提升。最后全部执行完后,函数结束,ctx作为临时变量将会被自动回收,然后执行context的析构函数,这里会自动将该context从栈中移除从而达到目的。
补充一下,do_run_one运行结束之前会调用lock.unlock(),也就是说在2次连续的do_run_one之间可能会发生CPU竞争,但哪怕是这里切换了进程也不要紧,此时唯一用到了“栈顶”这一地方的就只有分配内存与回收内存的逻辑中了,其它的业务逻辑不会受它影响(至少在我看到的范围内,看不到的地方只能上帝保佑了)。

再次声明,最后一段可靠性尚存疑,仅作参考讨论。

猜你喜欢

转载自blog.csdn.net/weixin_43827934/article/details/84631928