站在 rados tools 看 ceph(3)

2. global_init()

global_init() 是全局初始化函数,所有的 ceph 相关进程(rados,ceph,rbd等)都需要执行该操作,msgr-worker 和 log 线程都是在这一步创建的。该函数主要目的是进行参数的初始化解析工作、创建 CephContext、开启一些基础线程以及执行一些通用的预处理工作。

run_pre_init 标志位默认是 true,所以通常情况下,都会执行 global_pre_init()。下文会详细介绍。

block_signals() ,此函数屏蔽了 siglist[] 中的信号,这里是 SIGPIPE 信号。SIGPIPE 信号是服务端断开链接后发送给客户端的信号,一般来说客户端收到 SIGPIPE 信号会立刻中断进程。Ceph 不希望出现客户端突然中断,所以这里屏蔽了该信号。install_standard_sighandlers() 则是制定了某些信号的处理程序,包括:SIGSEGV、SIGABRT、SIGBUS、SIGILL、SIGFPE、SIGXCPU、SIGCFSZ、SIGSYS,具体处理方法见 handle_fatal_signal() 函数(本文这里不做分析)。

g_ceph_context->_log->set_flush_on_exit() 向退出函数注册 log_on_exit() 回调函数,该函数旨在退出时清空 log 指针,避免内存泄漏。

在 getuid() 循环体中,如果当前是 root 用户,则更改配置文件中的 userid 和 gruopid 属性为0(最高权限),并设置环境变量 HOME 为当前家目录。

globla_init() 最终返回一个 g_ceph_context 指针,该指针实际指向一个 CephContext 对象。CephContext表示由单个库用户持有的上下文。在同一个进程中可以有多个CephContexts。对于守护进程和实际程序(如本文最开始的 rados bench...),只有一个CephContext。CephContext包含配置、dout对象以及您可能希望在每次函数调用时传递给libcommon的任何其他内容。

boost::intrusive_ptr
global_init(const std::map *defaults,
        std::vector < const char* >& args,
        uint32_t module_type, code_environment_t code_env,
        int flags,
        const char *data_dir_option, bool run_pre_init)
{
    //标志位,避免一个进程中多次执行 global_init()
    static bool first_run = true;
    //执行 global_pre_init()...
    if (run_pre_init) {
        ceph_assert(!g_ceph_context && first_run);
        global_pre_init(defaults, args, module_type, code_env, flags);
    } else {
        ceph_assert(g_ceph_context && first_run);
    }
    //更改标志位,避免重复执行global_init()
    first_run = false;
    //屏蔽 SIGPIPE 信号
    int siglist[] = { SIGPIPE, 0 };
    block_signals(siglist, NULL);
    //加载信号处理器
    if (g_conf()->fatal_signal_handlers) {
        install_standard_sighandlers();
    }
    //退出时删除 log 指针,注意是内存中的 log 指针,不是日志文件
    if (g_conf()->log_flush_on_exit)
        g_ceph_context->_log->set_flush_on_exit();
    
    //读取 --setuser 配置参数,如果当前不是 root 则什么也不做,如果时 root,则根据配置参数更改
    if (getuid() != 0) {
        ...
    }
    else if (g_conf()->setgroup.length() ||
             g_conf()->setuser.length()) {
        ...
        g_ceph_context->set_uid_gid(uid, gid);
        g_ceph_context->set_uid_gid_strings(uid_string, gid_string);
        ...
        setenv("HOME", home_directory.c_str(), 1)
        ...
    }
    //生效更改的配置
    // Expand metavariables. Invoke configuration observers. Open log file.
    g_conf().apply_changes(nullptr);

    // call all observers now.  this has the side-effect of configuring
    // and opening the log file immediately.
    g_conf().call_all_observers();
    //打印先前发生的错误到日志文件中
    g_conf().complain_about_parse_errors(g_ceph_context);
    ...
    //内存泄漏检测
    if (g_conf()->debug_deliberately_leak_memory) {
        derr << "deliberately leaking some memory" << dendl;
        char *s = new char[1234567];
        (void)s;
        // cppcheck-suppress memleak
    } 
    ...
    return boost::intrusive_ptr{g_ceph_context, false};
}

2.1 global_pre_init()

global_pre_init() 是 global_init() 的预执行步骤,主要解析配置、启动 MonClient、启动 log 线程。这里的解析配置的顺序是:部分命令行参数、默认配置、配置文件 ceph.conf、环境变量、命令行参数。后一步的配置可以覆盖前一步获得的配置参数。因此命令行参数的优先级最高,默认配置的优先级最低。

ceph_argparse_early_args() 就是用来预解释命令含参数的,包括:conf、cluster、id、name。此外遇到“--version”则会直接打印版本号,然后退出程序;遇到“--show_args”则会打印出所有的命令行参数。

common_preinit() 返回了一个 CephContext 实例,对于一个实例进程来说,贯穿一生的唯一 CephContext 其实就是在这里创建的,上文 global_init() 返回的 g_ceph_context 指针实际就是这个 CephContext 对象。其中包括了 log、配置参数、网络通信等等一个 Ceph 进程所包含的各种模块的信息或者实例指针。

conf.parse_config_files() 读取配置文件的参数。默认使用 /etc/ceph/ceph.conf。conf.parse_env() 从环境读取“CEPH_ARGS”参数。conf.parse_argv() 再从命令行读取配置参数。

cct->_log->start() 启动 log 线程,开启日志。

mc_bootstrap.get_monmap_and_config() 配置网络通信,并开启3个 msgr_worker 线程。

do_argv_commands() 执行 --show-config[-val] 命令,打印指定参数值。 g_conf().complain_about_parse_errors() 则是向日志中写入错误信息。因为日志刚刚开启,所以没有在一开始发生错误的时候就写日志,而是收集起来,最后一起写入日志文件。

void global_pre_init(
  const std::map *defaults,
  std::vector < const char* >& args,
  uint32_t module_type, code_environment_t code_env,
  int flags)
{
    ...
    //预读取命令行参数
    CephInitParameters iparams = ceph_argparse_early_args(
    args, module_type,
    &cluster, &conf_file_list);
    ...
    //创建 CephContext
    CephContext *cct = common_preinit(iparams, code_env, flags);
    cct->_conf->cluster = cluster;
    global_init_set_globals(cct);
    auto& conf = cct->_conf;
    ...
    //读取配置文件参数
    int ret = conf.parse_config_files(c_str_or_null(conf_file_list),&cerr, flags);
    ...
    //读取环境变量参数
    conf.parse_env(cct->get_module_type());
    //读取命令行参数
    conf.parse_argv(args);
    //开启 log
    if (conf->log_early &&!cct->_log->is_started()) {
        cct->_log->start();
    }
    //开启网络通信
    if (!conf->no_mon_config) {
        conf.apply_changes(nullptr);
        MonClient mc_bootstrap(g_ceph_context);
        if (mc_bootstrap.get_monmap_and_config() < 0) {
            cct->_log->flush();
            cerr << "failed to fetch mon config (--no-mon-config to skip)"
                << std::endl;
            _exit(1);
        }
    }
    // do the --show-config[-val], if present in argv
    conf.do_argv_commands();

    // Now we're ready to complain about config file parse errors
   g_conf().complain_about_parse_errors(g_ceph_context); 
}

2.1.1 log

Ceph 对每个子系统的日志都预先定义了日志级别,支持动态修改。每条 log 都带有日志级别,低于预先定义的级别才会被打印,预定义级别可以在配置文件中修改,并且可以针对某个模块,如 osd、rbd 等。log 模块有单独的线程,只需要向该线程提交 job 即可,是异步的过程,对系统的性能影响很小。

下面给出了 log 类的头文件,里面定义了 log 所需的变量以及方法。DEFAULT_MAX_NEW =100: 表示新建日志的队列深度为100条,DEFAULT_MAX_RECENT=10000 表示最多记录最近写的10000条日志记录。

log 类其实可以简单的通过三个队列来理解。m_new 储存新加入的待写日志,m_recent 存储最近写完的日志,m_flush 表示即将写入日志文件的日志。就像是3个杯子,首先向 new 杯子倒水,如果不把 new 杯子里的水倒掉,最多倒100次 new 杯子就满了。new 杯子的水则倒向向 flush 杯子,flush杯子像是一个临时的中转杯,水在这里短暂停留,一旦用小本本记下了这杯水,就立刻把 flush 杯子的水倒入 recent 杯子。看起来 recent 杯子好像是个垃圾桶,明明都已经记载在小本上了,却还要留着这杯水,这是为了方便我们后续可以直接通过 recent 杯子查看倒了多少水、什么水。但是 recent 杯子也不是无底洞,它最多可以存10000次,满了就把杯底的开关打开,把水从底部放掉,这样可以优先把最不新鲜的水倒掉。

log 线程则通过 start() 来创建,具体调用链:cct->_log->start() -> Thread::create() -> Thread::try_create() -> pthread_create()。线程具体方法则是 *Log::entry()。

class Log : private Thread
{
  ...
  static const std::size_t DEFAULT_MAX_NEW = 100;
  static const std::size_t DEFAULT_MAX_RECENT = 10000;
  ...
  //三个队列,分别是最新的entries(待写日志)队列,最近写完的日志队列和即将写的日志队列(m_new flush后得到)
  EntryVector m_new;    ///< new entries
  EntryRing m_recent; ///< recent (less new) entries we've already written at low detail
  EntryVector m_flush; ///< entries to be flushed (here to optimize heap allocations)
  ...  

  //线程入口
  void *entry() override;
  ...

public:
  using Thread::is_started;

  Log(const SubsystemMap *s);
  ~Log() override;
  //退出时 flush 一次,清空 m_new,保证写入所有日志
  void set_flush_on_exit();
  ...
  //刷新
  void flush();
  //打印最近的日志
  void dump_recent();

  ...
  //提交日志
  void submit_entry(Entry&& e);
  //开启线程和终止线程
  void start();
  void stop();
  ...
}

注意上面 log 的构造函数: Log(const SubsystemMap *s),创建 Log 实例需要传入 SubsystemMap 。这个 map 实际也是在 CephContext 构造函数初始化列表中创建的。主要用来描述单个子系统的日志信息(即子模块,类似 rbd、rbd bench)。此处的日志级别区别与每条日志的级别,只有当日志级别小于等于该子系统日志级别时才会被打印,可在配置文件中更改子系统日志级别。

struct Subsystem {
  int log_level; // 日志级别
  int gather_level; // gather级别
  std::string name; // 子系统名称
  
  Subsystem() : log_level(0), gather_level(0) {}     
};

下面给出了日志写入文件操作的部分源代码。简单叙述整个过程,就是把 m_new 队列数据转移到 m_flush,读取 m_flush 队列中的每条记录,判断是否满足日志级别小于子系统级别的要求,同时把数据复制到 m_recent 队列,然后经过层层调用,最终通过 write() 写入日志文件。

void *Log::entry()
{
  reopen_log_file(); //打开日志文件,返回 fd 文件号,记录在 Log.cc 中的 m_fd 局部变量中
  {
    ...
    //循环刷新日志,m_new 转移到 m_flush,具体在 flush() 函数实现。
    while (!m_stop) {
      if (!m_new.empty()) {
        ...
        flush();
        ...
        continue;
      }
      ...
    }
    ...
  }
  //线程结束时,再次刷新
  flush();
  return NULL;
}


void Log::flush()
{
  {
    ...
    m_flush.swap(m_new);//把 m_new 队列数据转移到 m_flush
    m_cond_loggers.notify_all();//唤醒所有线程
    ...
  }
  _flush(m_flush, true, false);//实例化 m_flush 队列,数据记录在 m_recent
 ...
}


void Log::_flush(EntryVector& t, bool requeue, bool crash)
{
  ...
  for (auto& e : t) {
    //判断本条日志级别是否小于等于子系统日志级别,小于则为true
    bool should_log = crash || m_subs->get_log_level(sub) >= prio;
    //日志文件是否成功打开,是否写本条日志
    bool do_fd = m_fd >= 0 && should_log;
    bool do_syslog = m_syslog_crash >= prio && should_log;
    bool do_stderr = m_stderr_crash >= prio && should_log;
    bool do_graylog2 = m_graylog_crash >= prio && should_log;

    if (do_fd || do_syslog || do_stderr) {
      ...
      if (do_syslog) {
        syslog(LOG_USER|LOG_INFO, "%s", pos);
      }
      if (do_stderr) {
        std::cerr << m_log_stderr_prefix << std::string_view(pos, used) << std::endl;
      }
      ...
      if (do_fd) {
      ...
      if (m_log_buf.size() > MAX_LOG_BUF) {
        _flush_logbuf();
      }
    }
    //把数据存储压入m_recent
    if (requeue) {
      m_recent.push_back(std::move(e));
    }
  }
  t.clear();

  _flush_logbuf();
}


//再经过几次调用,最终通过 write() 函数把日志序列化到文件中。
void Log::_flush_logbuf()
{
  if (m_log_buf.size()) {
    _log_safe_write(std::string_view(m_log_buf.data(), m_log_buf.size()));
    m_log_buf.resize(0);
  }
}

大致了解了 log 线程的工作模式后,让我们来看看如何通过代码的方式记录日志,以及它是如何实现的?

随机挑选一条日志记录代码,具体如下。

ldout(cct, 20) << "Log depth=" << depth << " x=" << x << dendl;

可以看到和我们通常使用的 cout 命令有点像。<< 拼接字符串,dendl 结尾,ldout() 中传入两个参数,第一个是固定的 cct (CephContext),第二个代表日志级别:error = -1、warn = 0、info = 1、debug = 5、20 = trace。了解这些,已经能够完成记录日志的操作了。

ldout 主要在dout.h 文件中通过宏定义来实现,如下给出了3个日志输出方式:lsubdout、ldout、lderr,都通过 dout_impl() 方法实现。should_gather 标志位,判定是否满足输出级别要求。创建 MutableEntry 实例,通过submit_entr() 方法提交到 m_new 队列,交由 log 线程处理。

#define lsubdout(cct, sub, v)  dout_impl(cct, ceph_subsys_##sub, v) dout_prefix
#define ldout(cct, v)  dout_impl(cct, dout_subsys, v) dout_prefix
#define lderr(cct) dout_impl(cct, ceph_subsys_, -1) dout_prefix

#define dout_impl(cct, sub, v)                      \
  do {                                  \
  const bool should_gather = [&](const auto cctX) {         \
    if constexpr (ceph::dout::is_dynamic::value ||   \
          ceph::dout::is_dynamic::value) {     \
      return cctX->_conf->subsys.should_gather(sub, v);         \
    } else {                                \
      /* The parentheses are **essential** because commas in angle  \
       * brackets are NOT ignored on macro expansion! A language's  \
       * limitation, sorry. */                      \
      return (cctX->_conf->subsys.template should_gather());    \
    }                                   \
  }(cct);                               \
                                    \
  if (should_gather) {                          \
    ceph::logging::MutableEntry _dout_e(v, sub);                        \
    static_assert(std::is_convertible::value,        \
          "provided cct must be compatible with CephContext*"); \
    auto _dout_cct = cct;                       \
    std::ostream* _dout = &_dout_e.get_ostream();

#define dendl_impl std::flush;                                          \
    _dout_cct->_log->submit_entry(std::move(_dout_e));                  \
  }                                                                     \
  } while (0)

2.1.2 msgr-worker-x

说到 msgr-worker,它实际是 Messenger 模块创建的通信线程,用来监听和发送对象。在《Ceph 源码分析》一书中有简单介绍 SimpleMessenger,通过 pipe 传递消息。但是在目前的N版中,主要使用 AsyncMessenger,AsynsMessenger 使用 epoll 来监听端口发送消息,有异步、多路IO复用等功能,能够提升端口监听的数量以及降低资源消耗。

msgr-worker 创建过程的具体调用链如下:
global_init() -> global_pre_init() -> mc_bootstrap.get_monmap_and_config() -> Messenger::create_client_messenger() -> Messenger::create() -> new AsyncMessenger -> Stack->start()。

网络通信模块实在是纷繁复杂,想要顺畅、完整地介绍它,本人还做不到。这里就简单的介绍下 AsyncMessenger,关于网络通信模块详细介绍留待后续。

就从 Messenger::create() 开始吧。Meseenger 支持三种模式:simple、async、xio,可以通过指定 --ms_type=XX 参数来选择模式。AsyncMessenger 除了支持 posix 传输模式外,还支持 rdma 和 dpdk,通过 --ms_type=async+XX来配置。AsyncMessenger 首先创建一个 StackSingleton 单例,再调用 single->ready(),再到 NetworkStack::create() 函数创建 NetworkStack。

AsyncMessenger::AsyncMessenger(CephContext *cct, entity_name_t name,
                               const std::string &type, string mname, uint64_t _nonce)
  : SimplePolicyMessenger(cct, name,mname, _nonce),
    dispatch_queue(cct, this, mname),
    lock("AsyncMessenger::lock"),
    nonce(_nonce), need_addr(true), did_bind(false),
    global_seq(0), deleted_lock("AsyncMessenger::deleted_lock"),
    cluster_protocol(0), stopped(true)
{
  std::string transport_type = "posix";
  //支持 rdma 和 dpdk 传输模式
  if (type.find("rdma") != std::string::npos)
    transport_type = "rdma";
  else if (type.find("dpdk") != std::string::npos)
    transport_type = "dpdk";
        
  //创建 StackSingleton 单例
  auto single = &cct->lookup_or_create_singleton_object(
    "AsyncMessenger::NetworkStack::" + transport_type, true, cct);
  //创建 NetworkStack
  single->ready(transport_type);
  //创建 PosixWorker
  stack = single->stack.get();
  //开启 msgr-worker 线程
  stack->start();
  ...
}

struct StackSingleton {
  ...
  void ready(std::string &type) {
    if (!stack)
      stack = NetworkStack::create(cct, type);
  }
  ...
};

NetworkStack 构造函数如下。NetworkStack 中的 workers 用来保存多个 worker,每个 worker 都会创建一个 epoll(大多的网络编程中,都会使用基于事件通知的异步网络 IO 方式来实现,比如 Epoll 和 Kqueue,ceph 的网络模块使用的是Epoll)。worker 的作用主要就是监听端口和建立连接,使用 epoll 来进行 socket 事件驱动。3个 worker 保证了负载均衡,每次调用 connection 都会寻找连接数最少的 worker 线程。

NetworkStack::NetworkStack(CephContext *c, const string &t): type(t), started(false), cct(c)
{
  //读取 msger_worker 线程的数量配置
  num_workers = cct->_conf->ms_async_op_threads;
  ...
 //循环创建 woker,压入 workers 队列,在事件中心初始化
  for (unsigned i = 0; i < num_workers; ++i) {
    Worker *w = create_worker(cct, type, i);
    w->center.init(InitEventNumber, i, type);
    workers.push_back(w);
  }
}

class PosixWorker : public Worker {
  //监听端口
  int listen(entity_addr_t &sa,
         unsigned addr_slot,
         const SocketOptions &opt,
         ServerSocket *socks) override;
  //连接端口
  int connect(const entity_addr_t &addr, const SocketOptions &opts, ConnectedSocket *socket) override;
};

在 stack->start() 方法中,add_thread() 创建了线程的方法体(线程开启时,执行该方法),spawn_worker() 中通过 std::thread() 方法创建线程。线程的数量 num_workers 根据配置中的 ms.async.op.threads 来决定,默认为3。

 void NetworkStack::start(){
     ...
      for (unsigned i = 0; i < num_workers; ++i) { 
          if (workers[i]->is_init())
              continue;
          //创建线程主体
          std::function thread = add_thread(i);
          //开启线程
          spawn_worker(i, std::move(thread));
      }
     ...
 }

add_thread() 中设置了当前线程的名称:msgr-worker-{num},在事件中心注册 worker 线程,进行初始化工作。while 循环中则一直轮询处理 EventCenter 中的事件。EventCenter 是一个存储事件的容器,它通过注册的回调函数 EventCallbackRef 来针对的处理事件。事件类型有三种:time_events(定时事件)、external_events(外部事件,发送消息)、可读事件(epoll 监听的事件,接收消息)。

std::function NetworkStack::add_thread(unsigned i)
{
  Worker *w = workers[i];
  return [this, w]() {
      char tp_name[16];
      sprintf(tp_name, "msgr-worker-%u", w->id);
      //线程改名:msgr-worker-x
      ceph_pthread_setname(pthread_self(), tp_name);
      const unsigned EventMaxWaitUs = 30000000;
      w->center.set_owner();
      ldout(cct, 10) << __func__ << " starting" << dendl;
      w->initialize();
      w->init_done();
      //轮询事件中心,处理消息
      while (!w->done) {
        ldout(cct, 30) << __func__ << " calling event process" << dendl;

        ceph::timespan dur;
        int r = w->center.process_events(EventMaxWaitUs, &dur);
        if (r < 0) {
          ldout(cct, 20) << __func__ << " process events failed: "
                         << cpp_strerror(errno) << dendl;
          // TODO do something?
        }
        w->perf_logger->tinc(l_msgr_running_total_time, dur);
      }
      w->reset();
      w->destroy();
  };
}

至此,建立了网络通信模块的基础,后续需要连接某个 ip 端口时,需要从 stack 中申请一个负载最小的 msgr_worker 线程,通过 new AsyncConnection() 方式建立连接。可以在上述代码中看到消息处理的蛛丝马迹:w->center.process_events(),消息的传递都经由 EventCenter 事件中心,我们在实际操作中,只需要把消息封装成 message ,通过 dispatch_event_external() 提交给事件中心并注册为外部事件即可,然后由 connection 建立连接时指定的 msgr_worker 线程去处理消息的传递。

总结:global_init() 函数是通用了全局初始化函数,无论是客户端还是服务端,Ceph 的任意子系统都需要进行该初始化。主要内容包括:解析配置(默认配置、命令行参数、配置文件)、开启 log、开启网络通信、加载信号处理器,创建 CephContext。

你可能感兴趣的:(站在 rados tools 看 ceph(3))