Zeromq 源码全解析(2)

在开始前,建议先阅读一遍Zeromq中文指南

https://github.com/anjuke/zguide-cn
目的是学习基本的使用方法,以及面对高扩展需求时,Zeromq官方的解决方案
有些代码示例接口已经改变,但是不妨碍对Zeromq的理解与使用.

关于各APi的介绍会在源代码目录和网页中分别有介绍
代码中路径为
libzmq\doc
网页地址为
http://api.zeromq.org/
内容一致

经过简单的学习不难得出以下几个函数调用会启动zeromq的结论
接下来也是通过对这几个函数的来进行探索

接收端

void* zmq_ctx_new()
void* zmq_socket (void* ctx_, int type_)
int zmq_bind (void* s_, const char* addr_)
int zmq_recv (void* s_, void* buf_, size_t len_, int flags_)

发送端

void* zmq_ctx_new()
void* zmq_socket (void* ctx_, int type_)
int zmq_connect (void *s_, const char *addr_)

从接收端开始

zmq_ctx_new()

API介绍

libzmq\doc\zmq_ctx_new.txt
http://api.zeromq.org/master:zmq-ctx-new

zmq_ctx_new函数创建了zeromq的上下文环境,
从介绍中可以了解到zmq_ctx_new创建了Zeromq的上下文ctx_t,而且ctx_t是线程安全的,并且可以安全的在线程间传递

作为Zeromq的环境初始化接口,我们当然需要从这里开始探索Zeromq的整体设计

src/ctx.hpp

ctx_t 继承自 thread_ctx_t

thread_ctx_t
提供了以下功能的设置接口
set (int option_, const void *optval_, size_t optvallen_)
get (int option_, const void *optval_, size_t optvallen_)
ZMQ_THREAD_SCHED_POLICY 线程调度策略
ZMQ_THREAD_AFFINITY_CPU_ADD 绑定cpu核心
ZMQ_THREAD_AFFINITY_CPU_REMOVE 移除cpu核心绑定
ZMQ_THREAD_PRIORITY 线程优先级
ZMQ_THREAD_NAME_PREFIX 线程字符串别名
也提供了线程启动函数来进行线程的启动
start_thread (thread_t &thread_,thread_fn *tfn_,void *arg_,const char *name_) const

ctx_t 这个类复杂度较高,拥有很多函数,如果一一分析,不仅抓不到重点,而且让人一下接受几十个函数,并理清之间的关系,容易让人怀疑人生

void *zmq_ctx_new (void)
{
    // 首先是网络环境的初始化,分别是PGM和WINDOWS下的
    if (!zmq::initialize_network ()) {
        return NULL;
    }

	
	//直接创建ctx_t指针,而构造函数执行了一些数值初始化
	//当前 ctx_t 的状态
	//_tag (ZMQ_CTX_TAG_VALUE_GOOD),
	//启动标记
    //_starting (true),
	//当前是否处于关闭状态
    //_terminating (false),
	// 回收线程
    //_reaper (NULL),
	//同时socket最大打开数
    //_max_sockets (clipped_maxsocket (ZMQ_MAX_SOCKETS_DFLT)),
	//同时消息的最大数目
    //_max_msgsz (INT_MAX),
	//io线程数量
    //_io_thread_count (ZMQ_IO_THREADS_DFLT),
	// 该上下文是否永远不会终止
    //_blocky (true),
	//是否支持ipv6
    //_ipv6 (false),
	//是否使用零拷贝消息解析功能
    //_zero_copy (true)	
    zmq::ctx_t *ctx = new (std::nothrow) zmq::ctx_t;
    if (ctx) {
        if (!ctx->valid ()) {
            delete ctx;
            return NULL;
        }
    }
    return ctx;
}

相当于真的只做了初始化工作,而我们简单翻阅ctx_t的函数可以得到如下信息:
ctx_t有一个start_thread函数,肯定是后续的函数调用中进行的启动,让我们继续往下走

void *zmq_socket (void *ctx_, int type_)

http://api.zeromq.org/master:zmq-socket
libzmq\doc\zmq_socket.txt

void *zmq_socket (void *ctx_, int type_)
{
	//空指针检查,以及ctx_t检查
    if (!ctx_ || !(static_cast (ctx_))->check_tag ()) {
        errno = EFAULT;
        return NULL;
    }	
    zmq::ctx_t *ctx = static_cast (ctx_);
	//通过 type_ 创建了具体对象指针 并以基类 socket_base_t 形式返回
    zmq::socket_base_t *s = ctx->create_socket (type_);
    return (void *) s;
}

再看

zmq::socket_base_t *zmq::ctx_t::create_socket (int type_)
{
	//后续需要对_empty_slots进行操作,进行上锁
    scoped_lock_t locker (_slot_sync);
	//如果未启动当前 ctx 则进行启动
    if (unlikely (_starting)) {
		//来了来了,在该函数中,我们念念不忘的 start_thread 启动了
        if (!start ())
            return NULL;
    }
    
	// 一旦调用了zmq_ctx_term,将不能创建新套接字
    if (_terminating) {
        errno = ETERM;
        return NULL;
    }

	//如果当前已达到套接字上限,返回错误
    if (_empty_slots.empty ()) {
        errno = EMFILE;
        return NULL;
    }

    // 选择索引
    uint32_t slot = _empty_slots.back ();
    _empty_slots.pop_back ();

    //  生成唯一id
    int sid = (static_cast (max_socket_id.add (1))) + 1;

    // 创建套接字,并注册在其身上的mailbox
    socket_base_t *s = socket_base_t::create (type_, this, slot, sid);
    if (!s) {
        _empty_slots.push_back (slot);
        return NULL;
    }
	//该 ctx_t 上的 socket_base_t 数组
    _sockets.push_back (s);
	//该 ctx_t 上的 i_mailbox 数组
    _slots[slot] = s->get_mailbox ();

    return s;
}

//启动当前ctx
bool zmq::ctx_t::start ()
{
	//对数组中的 mailboxes 进行初始化,增加回收线程
    _opt_sync.lock ();	
    const int term_and_reaper_threads_count = 2;	
    const int mazmq = _max_sockets;	
    const int ios = _io_thread_count;
    _opt_sync.unlock ();
	
	
    int slot_count = mazmq + ios + term_and_reaper_threads_count;
    try {
		//vector 重设 capacity 上限
        _slots.reserve (slot_count);
        _empty_slots.reserve (slot_count - term_and_reaper_threads_count);
    }
    catch (const std::bad_alloc &) {
        errno = ENOMEM;
        return false;
    }
	//设置当前大小.
	//吐槽一下,一顿分析下来我竟然忘了 _slots 容器中装的是什么了,又回去看了一下,改成 _mailbox_slots应该会好一点
    _slots.resize (term_and_reaper_threads_count);

    //  Initialise the infrastructure for zmq_ctx_term thread.
	// 将关闭线程的 mailbox 绑定到 ctx 上
    _slots[term_tid] = &_term_mailbox;


	//创建回收线程并启动
    _reaper = new (std::nothrow) reaper_t (this, reaper_tid);
    if (!_reaper) {
        errno = ENOMEM;
        goto fail_cleanup_slots;
    }
    if (!_reaper->get_mailbox ()->valid ())
        goto fail_cleanup_reaper;
    _slots[reaper_tid] = _reaper->get_mailbox ();
    _reaper->start ();

	//创建指定数量的io线程启动且注册,当然包括其mailbox
    _slots.resize (slot_count, NULL);

    for (int i = term_and_reaper_threads_count;
         i != ios + term_and_reaper_threads_count; i++) {
        io_thread_t *io_thread = new (std::nothrow) io_thread_t (this, i);
        if (!io_thread) {
            errno = ENOMEM;
            goto fail_cleanup_reaper;
        }
        if (!io_thread->get_mailbox ()->valid ()) {
            delete io_thread;
            goto fail_cleanup_reaper;
        }
        _io_threads.push_back (io_thread);
        _slots[i] = io_thread->get_mailbox ();
        //io_thread 会使用 ctx_t 上的start_thread来启动成员函数 worker_routine ,进而启动当前平台下的io接口的
        //loop(), 再接下来就是经典的 reactor 模式, 从响应的fd中,找到对应的 poll_entry_t ,
        //通过判断响应的事件来调用挂接在io_thread上的对象的 in_event 或者 out_event 函数
        io_thread->start ();
    }

    //  In the unused part of the slot array, create a list of empty slots.
	
	//将可以分配的索引放入可用索引vector中.
    for (int32_t i = static_cast (_slots.size ()) - 1;
         i >= static_cast (ios) + term_and_reaper_threads_count; i--) {
        _empty_slots.push_back (i);
    }
	
	//启动完毕
    _starting = false;
    return true;

fail_cleanup_reaper:
    _reaper->stop ();
    delete _reaper;
    _reaper = NULL;

fail_cleanup_slots:
    _slots.clear ();
    return false;
}

再看 socket_base_t 对象的创建过程

典型的工厂模式,隐藏构造时的细节,用type来获取不同的目标对象
截取部分分析

zmq::socket_base_t *zmq::socket_base_t::create (int type_,
                                                class ctx_t *parent_,
                                                uint32_t tid_,
                                                int sid_)
{
    socket_base_t *s = NULL;
    switch (type_) {
        case ZMQ_REP:
			//可以跟着几个对象的构造进行查看,3个参数的传入其实是给基类 socket_base_t 所使用初始化
			//再根据Zeromq类结构图不难看出,不同的type_只是生成了不同socket_base_t的子类对象
            s = new (std::nothrow) rep_t (parent_, tid_, sid_);
            break;
			//其他构造方法
			.....
        default:
            errno = EINVAL;
            return NULL;
    }

    alloc_assert (s);

    if (s->_mailbox == NULL) {
        s->_destroyed = true;
        LIBZMQ_DELETE (s);
        return NULL;
    }

    return s;
}

再看 socket_base_t 的构造函数

zmq::socket_base_t::socket_base_t (ctx_t *parent_,
                                   uint32_t tid_,
                                   int sid_,
                                   bool thread_safe_) :
    //调用 own_t 的构造函数,用于维护对象的生命周期
    own_t (parent_, tid_),
    _tag (0xbaddecaf),
    _ctx_terminated (false),
    _destroyed (false),
    _poller (NULL),
    _handle (static_cast (NULL)),
    _last_tsc (0),
    _ticks (0),
    _rcvmore (false),
    _monitor_socket (NULL),
    _monitor_events (0),
    _thread_safe (thread_safe_),
    _reaper_signaler (NULL),
    _sync (),
    _monitor_sync ()
{
    options.socket_id = sid_;
    options.ipv6 = (parent_->get (ZMQ_IPV6) != 0);
    options.linger.store (parent_->get (ZMQ_BLOCKY) ? -1 : 0);
    options.zero_copy = parent_->get (ZMQ_ZERO_COPY_RECV) != 0;
    
	//根据线程安全选项来决定是否生成线程安全的 mailbox 对象
    if (_thread_safe) {
        _mailbox = new (std::nothrow) mailbox_safe_t (&_sync);
        zmq_assert (_mailbox);
    } else {
        mailbox_t *m = new (std::nothrow) mailbox_t ();
        zmq_assert (m);

        if (m->get_fd () != retired_fd)
            _mailbox = m;
        else {
            LIBZMQ_DELETE (m);
            _mailbox = NULL;
        }
    }
}

可以这么看 zmq_socket 在 ctx 上插入了一个 socket_base_t 对象并将指针抛出来,由 own_t 来维护生命周期

再看

zmq_bind (_responder, "tcp://*:9000");


int zmq_bind (void *s_, const char *addr_)
{
	//转换成 socket_base_t 指针
    zmq::socket_base_t *s = as_socket_base_t (s_);
    if (!s)
        return -1;
	//进行地址的解析和绑定地址
    return s->bind (addr_);
}

函数非常长,主要是因为该函数是进行地址解析,还需要根据不同的协议执行不同的函数调用操作
同样,我们暂时只对其中一种模式进行分析

int zmq::socket_base_t::bind (const char *endpoint_uri_)
{
	//根据线程安全拍段进行上锁准备
    scoped_optional_lock_t sync_lock (_thread_safe ? &_sync : NULL);

    if (unlikely (_ctx_terminated)) {
        errno = ETERM;
        return -1;
    }

	// 执行可能存在的被挂起的命令
    int rc = process_commands (0, false);
    if (unlikely (rc != 0)) {
        return -1;
    }

   //以://为分割对传入的协议和地址端口进行分片
   //并对传入协议进行检查
    std::string protocol;
    std::string address;
    if (parse_uri (endpoint_uri_, protocol, address)
        || check_protocol (protocol)) {
        return -1;
    }
	
	
	....


	//以下传输方式需要在io线程中进行,所以我们选择一个io线程
    io_thread_t *io_thread = choose_io_thread (options.affinity);
    if (!io_thread) {
        errno = EMTHREAD;
        return -1;
    }

    if (protocol == protocol_name::tcp) {
		//创建tcp 监听对象
        tcp_listener_t *listener =
          new (std::nothrow) tcp_listener_t (io_thread, this, options);
        alloc_assert (listener);
		//设置地址
        rc = listener->set_local_address (address.c_str ());
        if (rc != 0) {
            LIBZMQ_DELETE (listener);
            event_bind_failed (make_unconnected_bind_endpoint_pair (address),
                               zmq_errno ());
            return -1;
        }

        // Save last endpoint URI
        listener->get_local_address (_last_endpoint);
		//将节点插入子树中,
        add_endpoint (make_unconnected_bind_endpoint_pair (_last_endpoint),
                      static_cast (listener), NULL);
        options.connected = true;
        return 0;
    }
	...

    zmq_assert (false);
    return -1;
}

//传入绑定的CPU下标
zmq::io_thread_t *zmq::ctx_t::choose_io_thread (uint64_t affinity_)
{
    if (_io_threads.empty ())
        return NULL;

    //根据cpu偏好以及当前的io压力来选择压力最小的io线程并返回
    int min_load = -1;
    io_thread_t *selected_io_thread = NULL;
    for (io_threads_t::size_type i = 0; i != _io_threads.size (); i++) {
        if (!affinity_ || (affinity_ & (uint64_t (1) << i))) {
            int load = _io_threads[i]->get_load ();
            if (selected_io_thread == NULL || load < min_load) {
                min_load = load;
                selected_io_thread = _io_threads[i];
            }
        }
    }
    return selected_io_thread;
}


void zmq::socket_base_t::add_endpoint (
  const endpoint_uri_pair_t &endpoint_pair_, own_t *endpoint_, pipe_t *pipe_)
{
	//将新节点插入endpoint_
    launch_child (endpoint_);
	
	//插入ctx
    _endpoints.ZMQ_MAP_INSERT_OR_EMPLACE (endpoint_pair_.identifier (),
                                          endpoint_pipe_t (endpoint_, pipe_));

    if (pipe_ != NULL)
        pipe_->set_endpoint_pair (endpoint_pair_);
}

void zmq::own_t::launch_child (own_t *object_)
{
    //  插入
    object_->set_owner (this);

    //  向object_所属的io线程发送plug消息,在执行process_plug
    send_plug (object_);

    //  设置object_归属权
    send_own (this, object_);
}

非常值得一提的是关于 tcp_listener_t 的 plug 过程(初始化),首先是加入到当前 socket_base_t 中,然后是向 tcp_listener_t 发起一个 plug 消息,在 io_thread_t 的 mailbox_t 中进行缓存, 再在下一次 loop 循环中进行 tcp_listener_t 的 process_plug
说了这多,简单来说,进行 bind 的操作后,并不是马上就绑定上,虽然时间很短,但其实是一个异步的流程.

那这一切是不是理所当然的呢? tcp_listener_t 的指针已经在手里,何必大费周章的用消息启动?
个人说下自己的见解,当Zmq开启多线程模式时,直接执行 process_plug 操作可能会不在 tcp_listener_t 所属线程中执行,也就是破坏了 actor 设计初衷, 当多个线程同时对一个内存进行操作时,后果不必多说,这种调用顺序更是绝对不能允许的.

将协议绑定后,我们调用 zmq_recv 来等待消息的接受, 可以通过 flags_ 字段来设置阻塞与非阻塞模式

int zmq_recv (void *s_, void *buf_, size_t len_, int flags_)
{
    zmq::socket_base_t *s = as_socket_base_t (s_);
    if (!s)
        return -1;
		
	//初始化 zmq_msg_t 
    zmq_msg_t msg;
    int rc = zmq_msg_init (&msg);
    errno_assert (rc == 0);

    int nbytes = s_recvmsg (s, &msg, flags_);
    if (unlikely (nbytes < 0)) {
        int err = errno;
        rc = zmq_msg_close (&msg);
        errno_assert (rc == 0);
        errno = err;
        return -1;
    }

    //  An oversized message is silently truncated.
	//判断是否超过给定的大小,
    size_t to_copy = size_t (nbytes) < len_ ? size_t (nbytes) : len_;

    //  We explicitly allow a null buffer argument if len is zero
	//如果比给定的大小大,则进行拷贝
    if (to_copy) {
        assert (buf_);
        memcpy (buf_, zmq_msg_data (&msg), to_copy);
    }
    rc = zmq_msg_close (&msg);
    errno_assert (rc == 0);

    return nbytes;
}

static int s_recvmsg (zmq::socket_base_t *s_, zmq_msg_t *msg_, int flags_)
{
	//调用 socket_base_t 的recv函数
	//以我们目前来说实际上就是调用的 rep_t 的 xrecv,在 socket_base_t 中,recv 调用的虚函数 xrecv 而 xrecv 将会被子类重写
    int rc = s_->recv (reinterpret_cast (msg_), flags_);
    if (unlikely (rc < 0))
        return -1;

    //  Truncate returned size to INT_MAX to avoid overflow to negative values
    size_t sz = zmq_msg_size (msg_);
    return static_cast (sz < INT_MAX ? sz : INT_MAX);
}

看完接收端的启动流程后,再看连接端的函数调用
前两个函数调用是一致的,区别是第三个
zmq_connect来发起连接

int zmq_connect (void *s_, const char *addr_)
{
    zmq::socket_base_t *s = as_socket_base_t (s_);
    if (!s)
        return -1;
    return s->connect (addr_);
}

依然只截取部分进行分析
int zmq::socket_base_t::connect (const char *endpoint_uri_)
{
    scoped_optional_lock_t sync_lock (_thread_safe ? &_sync : NULL);

    if (unlikely (_ctx_terminated)) {
        errno = ETERM;
        return -1;
    }

    
	//执行任何可能被挂起的命令
    int rc = process_commands (0, false);
    if (unlikely (rc != 0)) {
        return -1;
    }

    
	//解析 endpoint_uri_字符串 以 :// 为边界进行分别
    std::string protocol;
    std::string address;
    if (parse_uri (endpoint_uri_, protocol, address)
        || check_protocol (protocol)) {
        return -1;
    }

    //DEALER SUB PUB REQ 不支持对一个端点同时开启多个会话,进行判断当前会话是否存在
    const bool is_single_connect =
      (options.type == ZMQ_DEALER || options.type == ZMQ_SUB
       || options.type == ZMQ_PUB || options.type == ZMQ_REQ);
    if (unlikely (is_single_connect)) {
        if (0 != _endpoints.count (endpoint_uri_)) {
            // There is no valid use for multiple connects for SUB-PUB nor
            // DEALER-ROUTER nor REQ-REP. Multiple connects produces
            // nonsensical results.
            return 0;
        }
    }

	.....

    //选择io线程去运行我们的会话对象
    io_thread_t *io_thread = choose_io_thread (options.affinity);
    if (!io_thread) {
        errno = EMTHREAD;
        return -1;
    }

    address_t *paddr =
      new (std::nothrow) address_t (protocol, address, this->get_ctx ());
    alloc_assert (paddr);

    //  Resolve address (if needed by the protocol)
    if (protocol == protocol_name::tcp) {
        //  Do some basic sanity checks on tcp:// address syntax
        //  - hostname starts with digit or letter, with embedded '-' or '.'
        //  - IPv6 address may contain hex chars and colons.
        //  - IPv6 link local address may contain % followed by interface name / zone_id
        //    (Reference: https://tools.ietf.org/html/rfc4007)
        //  - IPv4 address may contain decimal digits and dots.
        //  - Address must end in ":port" where port is *, or numeric
        //  - Address may contain two parts separated by ':'
        //  Following code is quick and dirty check to catch obvious errors,
        //  without trying to be fully accurate.
		//对提供的字符串进行解析
        const char *check = address.c_str ();
        if (isalnum (*check) || isxdigit (*check) || *check == '['
            || *check == ':') {
            check++;
            while (isalnum (*check) || isxdigit (*check) || *check == '.'
                   || *check == '-' || *check == ':' || *check == '%'
                   || *check == ';' || *check == '[' || *check == ']'
                   || *check == '_' || *check == '*') {
                check++;
            }
        }
        
        rc = -1;
        
        //检查地址是否是安全有效的
        if (*check == 0) {
            check = strrchr (address.c_str (), ':');
            if (check) {
                check++;
                if (*check && (isdigit (*check)))
                    rc = 0; //  Valid
            }
        }
        if (rc == -1) {
            errno = EINVAL;
            LIBZMQ_DELETE (paddr);
            return -1;
        }
        
        推迟解决方案配置
        paddr->resolved.tcp_addr = NULL;
    }
	
	.....

    //  Save last endpoint URI
    paddr->to_string (_last_endpoint);

    add_endpoint (make_unconnected_connect_endpoint_pair (endpoint_uri_),
                  static_cast (session), newpipe);
    return 0;
}

void* zmq_ctx_new()
void* zmq_socket (void* ctx_, int type_)
int zmq_bind (void* s_, const char* addr_)
int zmq_recv (void* s_, void* buf_, size_t len_, int flags_)

接收端时序图

app ØMQ ctx_t reaper_t io_thread_t socket_base_t tcp_listener_t zmq_ctx_new() zmq::initialize_network () 根据当前环境 进行网络初始化 new ctx_t 生成上下文并返回 ctx_t* zmq_socket (ctx_t*,type_) ctx_t->>create_socket(type_) start () new reaper_t* new io_thread_t* ØMQ正式启动, 创建索引集合,回 收线程,关闭邮箱 ,注册mailbox socket_base_t::create () 利用工厂模式,使用t ype创建出不同的s ocket_base _t子类,然后返回, 所以ØMQ相当一部分 的文件都是继承自so cket_base_t的子类 socket_base_t* 同时将 socket_base_t* 注册到ctx_t中 socket_base_t* socket_base_t* zmq_bind() bind (const char *endpoint_uri_) new tcp_listener_t* 以tcp类型为例,str eam_listener _base_t的子类,注 册到_endpoints上 add_endpoint() send_plug() process_command ->> process_plug set_pollin() zmq_recv (socket_base_t*, void*, size_t, int) recv有多种类似的方法 ,zmq_msg_rec v,zmq_recvms g,但其实都是同一套逻辑 zmq_msg_t msg s_recvmsg(scoket_baset_t*,msg,flags_) recv(&msg_) size_t size = zmq_msg_size (msg_) size 内容通过参数传出 size app ØMQ ctx_t reaper_t io_thread_t socket_base_t tcp_listener_t

void* zmq_ctx_new()
void* zmq_socket (void* ctx_, int type_)
int zmq_connect (void *s_, const char *addr_)
int zmq_send (void *s_, const void *buf_, size_t len_, int flags_)

发送时序图

app ØMQ ctx_t dealer_t io_thread_t socket_base_t session_base_t tcp_connecter_t stream_conn zmtp_engine_t zmq_ctx_new() zmq::initialize_network () 根据当前环境 进行网络初始化 new ctx_t 生成上下文并返回 ctx_t* zmq_socket (ctx_t*,type_) ctx_t->>create_socket(type_) start () new dealer_t* new io_thread_t* ØMQ正式启动, 创建索引集合,回 收线程,关闭邮箱 ,注册mailbox socket_base_t::create () 利用工厂模式,使用t ype创建出不同的s ocket_base _t子类,然后返回, 所以ØMQ相当一部分 的文件都是继承自so cket_base_t的子类 socket_base_t* 同时将 socket_base_t* 注册到ctx_t中 socket_base_t* socket_base_t* zmq_connect() connect() session_base_t::create() session_base_t* launch_child(session_base_t*) send_command() process_plug if(_active == true){ start_connecting()} session_base_t::create_connecter_tcp() session_base_t::create_connecter_tcp() new tcp_connecter_t* send_command() process_plug start_connecting() open() socket套接字 add_fd() socket套接字 这个时候如果 没有成功连接 则会一直进行重连 out_event() connect() create_engine() new stream_connecter_base_t* terminate():关闭自己 send_attach process_attach() pipe_t 初始化 send_bind(pipe_t*) attach_pipe() xattach_pipe() _fq.attach (pipe_),_lb.attach (pipe_) plug(),plug_internal () 由于 session 和 engine 是绑定在一起的,所以不用进行消息驱动 zmq_sned send app ØMQ ctx_t dealer_t io_thread_t socket_base_t session_base_t tcp_connecter_t stream_conn zmtp_engine_t

你可能感兴趣的:(Zeromq)