goyas 2019-12-22T02:00:25+00:00 qyran@aliyun.com GTC china 2019参会整理 2019-12-21T20:49:00+00:00 goyas http://goyas.github.io/gtc-2019 NVIDIA GTC 2019在苏州金湖国际会议中心举行,由于同事有其他会议冲突,所以我代替他来参加了此次会议。作为刚接触GPU和机器学习不久的新人来说,感觉进入了一个新世界,深刻体验到技术的革新迭代之快,英伟达通过对GPU的全栈优化,实现摩尔定理的加速。

2019年12月16日~2019年12月17日
头两天主要是培训课,这个课程还比较贵,大概扫描了下,基本都在人民币1000元以上,培训有相当部分的学生。由于之前同事没有预定,但不能来了啥也不做吧,找路上认识的腾讯哥们儿用他的工牌进入了教室(迫不得已)。修了《深度学习基础—自然语言处理》和《深度学习基础—多数据类型》两门课程,本来想学习下《深度学习基础—用多GPU训练神经网络》这门课程的(想到可能跟我们现在的业务有些关系),但是没有认识的人,没进去了。顺利完成两门课程学习,拿到NVIDIA发的证书,其中《自然语言处理》有道小编程题,难到了部分同学。通过两门课程学习,有个科普和扫盲的效果。

2019年12月18日
今天的重头戏是NVIDIA的首席执行官黄仁勋的主题演讲,后面官网应该有现场视频。今年参会的人很多,放两张图大家感受下。

据说2017年因为Volta GPU的发布,Keynote给人带来的震撼感比较强烈,想比较而言,今年没有推出新的GPU硬件平台,只发布了一款由软件定义的下一代SoC Orin(这个在后面会详细介绍)。当然,这也比较合理,因为从NVIDIA的角度,就算有存量硬件技术,也需要控制一下推出的节奏。因为在已发布的技术还未被市场充分使用的情况下,再引入新的突破性的技术点,从商业角度来看并不划算。要知道,V100的混合精度加速技术,也是在今年开始较大规模的被行业接受。

老黄的主题演讲内容包括:

RTX的新进展

去年,英伟达发布了RTX新一代GPU架构——Turing(图灵),以及一系列基于图灵架构的RTX GPU。黄仁勋表示图灵架构为英伟达十多年来在计算机图形领域最重要的创新,将光线追踪技术引入英伟达的GPU中。他今天宣布了6款支持RTX的游戏;除此之外,英伟达还创造出了Max-Q设计,它将超高的GPU能效和总体系统优化集于一身,可以用于轻薄的高性能笔记本电脑;同时,随着云计算的普及,英伟达和腾讯合作推出了START云端游戏服务。

深度推荐系统

百度AIBox推荐系统采用英伟达AI,这个系统基于英伟达Telsa v100 GPU,利用这些TB级的数据集去创建一个模型、在GPU上训练这些数据,然后把它放到GPU的内存当中去训练这种TB级别的数据,GPU训练成本只有CPU的十分之一,并且支持更大规模的模型训练;阿里巴巴搭建的推荐系统采用了英伟达的T4 GPU,推荐系统的吞吐量得到了大幅提升。面对每秒几十亿次的推荐请求,CPU速度只有3 QPS,英伟达GPU则提升到了780 QPS,提升百倍。推荐系统的两大难题:一是推荐模型及其复杂,需要处理的参数非常多,这就意味着需要非常强的计算能力。另外一个难题是推荐系统需要进行实时计算并给出反馈。使用擅长并行计算的GPU构建推荐系统比使用CPU构建推荐系统成本大幅降低或性能实现了百倍提升。

软硬件结合

英伟达今年6月也宣布CUDA年底前支持Arm生态系统,让基于Arm的芯片可以更多地应用于超算系统中进行更多地深度学习计算。今天演讲中提到了NVIDIA HPC for ARM首个参考架构,通过cuda加速ARM,把ARM服务器打造成HPC和AI的理想选择。据介绍,GPU+Arm的硬件,加上CUDA以及TensorFlow的优化,Arm进行深度学习的性能是x86处理器性能的96%;通过每个CPU连接4个Volta GPU,搭配4个Mellanox CX5网卡,新一代CX6获得了难以置信的强劲性能;还介绍了在DGX-2上运行Magnum IO GPU Direct Storage技术,可实时对超大数据进行可视化处理。
软件方面,去年英伟达发布了TensorRT5,计算图优化编译器,通过优化PyTorch和TensorFlow等框架中训练出来的AI模型,减少计算和内存访问,让模型在GPU上运行的效率大幅提升。今年推出了TensorRT7,它支持各种类型的RNN、Transformer和CNN。相比TRT5只支持30中变换,TRT 7能支持1000多种不同的计算变换和优化。

“核弹”产品——下一代的汽车和机器人技术Orin

老黄在演讲当中提到,该芯片由170亿个晶体管组成,凝聚着英伟达团队为期四年的努力。Orin系统级芯片集成了英伟达新一代GPU架构和Arm Hercules CPU内核以及全新深度学习和计算机视觉加速器,每秒可运行200万亿次计算(200TOPS),几乎是英伟达上一代Xavier系统级芯片性能的7倍。Orin可处理在自动驾驶汽车和机器人中同时运行的大量应用和深度神经网络,达到了ISO 26262 ASIL-D等系统安全标准。
作为一个软件定义平台,DRIVE AGX Orin能够赋力从L2级到L5级完全自动驾驶汽车开发的兼容架构平台,助力OEM开发大型复杂的软件产品系列。由于Orin和Xavier均可通过开放的CUDA、TensorRT API及各类库进行编程,因此开发者能够在一次性投资后使用跨多代的产品。 老黄的演讲完之后,逛了下几个展台,参加展会的厂商比较多,服务器硬件头部玩家浪潮、新华三以及互联网BAT、滴滴、字节跳动都有参加。展台的内容也比较丰富,从服务器硬件到深度学习软件平台,从AI机器人到智能驾驶,从ARM芯片到VR都有涉及,我们阿里巴巴公司的展台就紧挨着NVIDIA,主题内容是阿里云GPU云服务让AI更高效更简单。浏览完展台后,根据自己从事工作技术的特点,选择了几个跟我们组目前工作联系比较紧密的session,想看看业界对于多机多卡的的大规模计算平台是怎么发挥GPU的算力。带着这个思考,去关注了相应的session,而一些跟我眼前工作不直接相关的session,比如运营商5G通信网络AI研发与实践、中国移动人工智能规划及发展则是期望对AI的一些应用场景建立一些更直观的体感或者从宏观层面了解其发展。

  • 大规模算力平台构建和多机多卡线性扩展
  • 百度凤巢基于HGX-2的CTR模型训练方案
  • GPU全链路优化方案助力金融视觉平台
  • 运营商5G通信网络AI研发与实践
  • 中国移动人工智能规划及发展
  • NVIDIA vGPU在Linux KVM中的新优化和提升
  • NVIDIA GPU和Mellanox网络计算技术挑战AI性能新极限

大规模算力平台构建和多机多卡线性扩展是腾讯高性能计算服务星辰-机智团队带来的分享。分享主要包括大规模算力平台构建、多机多卡线性扩展、业务落地与运营三个部分。平台构建方面基于K8S提供不同训练框架、不同cuda版本的基础镜像,镜像仓库支持用户自定义,用演讲人的话说容器开箱即用;物理架构方面结点内不同CPU走QPI连接,同时CPU通过PCIe Switch和GPU通信,同一个PCIe Switch下的GPU通过GPU Direct RDMA连接。结点间通过100Gbps RDMA通信。在多机多卡部分,主要介绍了一个单机IO“无锁”队列技术、分层RingAllReduce算法(这个之前在知乎文章中有介绍,恰好这次分享的作者就是这篇文章的作者)以及AutoML超参搜索。他们的目标是打造腾讯AI基础设施。 总体感觉:腾讯这个组做的事情有点类似于。在GPU集合通信方面他们应该采用的还是NCCL,不管是硬件架构还是算法支持,都是落户于我们平台的。但在资源集中管理、统一调度以及任务化方面可能做的比我们要好。问了两个问题:1、针对云上多租户场景,怎么做资源调度和分配?2、介绍的分层RingAllduce怎么针对带宽做数据传输限制的?回答1、目前还没接入腾讯云,下一步正在考虑接入。回答2、现在内部节点GPU挂载PCIe Switch下面,通信都是走的PCIe,所以没有高带宽和低带宽的差异,所以对数据传输没有啥限制。

百度凤巢基于HGX-2的CTR模型训练方案是来自百度凤巢和基础架构部的分享。分享主要包括百度新一代CTR训练方案AIBox整体架构、百度AI计算平台孔明和AI计算机X-MAN、AIBox软硬协同解决存储、计算、通信挑战;百度AI计算机X-MAN是集计算、存储、网络关键技术融合一起的一体机(一体机貌似是个趋势:阿里内部的POLARDB Box数据库一体机、华为FusionCube一体机……)。向上对接大规模分布式训练平台孔明,再往上就是深度学习训练系统AIBox,这一套提供底层的基础平台承接广告、推荐等高价值的业务应用。X-MAN到目前为止经历了1.0到4.0的迭代,从最初的单机16卡,支持64卡扩展到引进液冷高效散热;再到模块化:NVLink高速互联背板、100G RDMA节点间互联网络、独立的Mezz和PCIe卡系统。以及目前行业首个4路CPU的超级AI计算机。再搭配飞浆PaddlePaddle,借力生态优势,加速算法迭代。据现场介绍,单个X-MAN GPU节点可以替换100个CPU节点,而AIBox 19年6月在CTR模型上全流量上线,搜索广告、图片凤巢、商品广告等主要模型全面切换AIBox。 个人感觉:百度这几年在AI方面的积累确实领先其他公司,不管是无人驾驶还是AI计算平台。而由于要赶其他会场,就没有提问。

GPU全链路优化方案助力金融视觉平台是蚂蚁金服认知计算和知识图谱团队的分享,因为之前看介绍有GPU训练和预测优化方案和成果,包括基于nccl2和gpu direct rdma的hierarchical allreduce的多机多卡训练,可以在NVIDIA Tesla V100的8卡集群接近线性加速比。想跟这个团队推荐下我们的***,会后沟通才了解到之前有我们的PM跟他们接触过。回去后他去了解下之前遇到了什么问题,然后看看是不是可以更进一步的合作。

运营商5G通信网络AI研发与实践,为参会者阐述运营商人工智能发展思路、技术路线和典型案例。讲解运营商网络重构、智慧运营和5G规划建设中的难点和瓶颈,以及人工智能技术所能发挥的成效、基于NVIDIA GPU的工程实践。

中国移动人工智能规划及发展,中国移动统一AI平台采用Kubernetes+Docker的基础架构,以 NVIDIA NGC提供的镜像为基础,集成了TensorFlow、PyTorch、Caffe等主流AI算法框架,基于 RAPIDS 算法库利用GPU实现对传统机器学习的10倍以上加速,规模化承载AI应用,为集团节约成本高达5亿元/年。

NVIDIA GPU和Mellanox网络计算技术挑战AI性能新极限是来自mellanox公司Marketing的Qingchun Song的分享,主要介绍了Socket Direct、Adaptive Routing、RDMA and GPU Direct、SHARP-Data Aggregation四个方面。整体感觉mellanox公司在加速HPC/AI框架上做了很多工作,在局部性能性能上也取得了不错的效果,比如:把集合通信算法AllReduce跑在他们的IB交换机,能使计算时间从30~40us缩短到3~4us;结合NVIDIA的集合通信库NCCL他们采用SHARP技术,在ResNet50上性能提升了10%~20%。。。 个人感觉:mellanox提供了很多有意义的性能提升技术,但是这些都是要基于使用他们的硬件,而这对于如果一个产品刚开始没有使用他们家的产品,又想利用他们家的技术来获得性能提升,可能就需要整体硬件的更迭改造,但是往往收益又赶不上这刮骨疗伤的成本,鱼和熊掌不可兼得啊!

]]>
enable_shared_from_this用法分析 2019-12-01T19:29:00+00:00 goyas http://goyas.github.io/enable_shared_from_this 一、背景

为什么需要异步编程文章末尾提到,”为了使socket和缓冲区(read或write)在整个异步操作的生命周期一直保持活动,我们需要采取特殊的保护措施。你的连接类需要继承自enabled_shared_from_this,然后在内部保存它需要的缓冲区,而且每次异步调用都要传递一个智能指针给this操作”。本文就详细介绍为什么使用enabled_shared_from_this就能保证对象的生命周期,以及enabled_shared_from_this内部的具体实现分析。

二、为什么需要保证对象生命周期


首先想象下同步编程,比如socket建立connect后,read或者write数据,因为是同步阻塞的,数据传输完后,socket对象就已经完成了此次任务,此时就算对象销毁,也并不会引起异常。但是异步编程就不一样了,当一个线程调用一个异步函数(例如:该函数还是socket写文件任务),该函数会立即返回,尽管规定的任务还没有完成,这样线程就会执行异步函数的下一条语句,而不会被挂起。只有当”写文件任务”完成后,由新的线程发送完成消息来执行结果同步,但是当新的线程完成”写文件任务”后,再发送过来,此时异步函数调用方对象是否还存在,这就是个需要解决的问题,这也就是为什么需要保证对象的生命周期。

更加直白一点的例子,假设你需要做下面的操作:

io_service service;
ip::tcp::socket sock(service);
char buff[512];
...
read(sock, buffer(buff));

在这个例子中,sock和buff的存在时间都必须比read()调用的时间要长。也就是说,在调用read()返回之前,它们都必须有效。你传给一个方法的所有参数在方法内部都必须有效。当我们采用异步方式时,事情会变得比较复杂。

io_service service;
ip::tcp::socket sock(service);
char buff[512];
void on_read(const boost::system::error_code &, size_t) {}
...
async_read(sock, buffer(buff), on_read);

在这个例子中,sock和buff的存在时间都必须比async_read()操作本身时间要长,但是read操作持续的时间我们是不知道的,因为它是异步的。当socket满足条件,有数据可读时,此时操作系统会把数据发送到缓冲区,触发async_read的回调函数on_read执行,on_read执行来通过socket读取数据到buffer,所以必须socket和buffer的生命周期要能得到保证。那究竟用什么方法呢?

三、实践中使用方法


异步编程时,我们在传入回调函数的时候,通常会想要其带上当前类对象的上下文,或者回调本身就是类成员函数,那这个工作自然非this指针莫属了,像这样:

void sock_sender::post_request_no_lock()
{
    Request &req = requests_.front();
    boost::asio::async_write(
		*sock_ptr_, 
		boost::asio::buffer(req.buf_ptr->get_content()),
        boost::bind(&sock_sender::self_handler, this, _1, _2));
}

然而回调执行的时候并一定对象还存在。为了确保对象的生命周期大于回调,我们可以使类继承自enable_shared_from_this,然后回调的时候使用bind传入shared_from_this()返回的智能指针。由于bind保存的是参数的副本,bind构造的函数对象会一直持有一个当前类对象的智能指针而使其引用计数不为0,这就确保了对象的生命周期大于回调中构造的函数对象的生命周期,像这样:

class sock_sender : public boost::enable_shared_from_this<sock_sender>
{
    //...
};
void sock_sender::post_request_no_lock()
{
    Request &req = requests_.front();
    boost::asio::async_write(
		*sock_ptr_,
        boost::asio::buffer(req.buf_ptr->get_content()),
        boost::bind(&sock_sender::self_handler, shared_from_this(), _1, _2));
}

“实际上边已经提到了,延长资源的生命周期防止使用它时已经被释放。这种问题绝大部分出现在异步调用的时候。因为异步函数的执行时间点无法确定。异步函数可能会使用异步调用之前的变量(比如类对象),这样就必须保证该变量在异步执行期间有效。如何做到这一点呢?只需要传递一个指向自身的shared_ptr(必须使用shared_from_this())给异步函数。因为这个拷贝过程使得对资源的引用计数加一。

四、关于enable_shared_from_this的原理分析


首先要说明的一个问题是:如何安全地将this指针返回给调用者。一般来说,我们不能直接将this指针返回。
想象这样的情况,该函数将this指针返回到外部某个变量保存,然后这个对象自身已经析构了,但外部变量并不知道,此时如果外部变量使用这个指针,就会使得程序崩溃。

使用智能指针shared_ptr看起来是个不错的解决方法。但问题是如何去使用它呢?我们来看如下代码:

#include <iostream>
#include <boost/shared_ptr.hpp>
class Test
{
public:
    //析构函数
    ~Test() { std::cout << "Test Destructor." << std::endl; }
    //获取指向当前对象的指针
    boost::shared_ptr<Test> GetObject()
    {
        boost::shared_ptr<Test> pTest(this);
        return pTest;
    }
};
int main(int argc, char *argv[])
{
    {
        boost::shared_ptr<Test> p( new Test( ));
        std::cout << "q.use_count(): " << q.use_count() << std::endl; 
        boost::shared_ptr<Test> q = p->GetObject();
    }
    return 0;
}

运行后,程序输出:

  Test Destructor.
  q.use_count(): 1
  Test Destructor.

可以看到,对象只构造了一次,但却析构了两次。并且在增加一个指向的时候,shared_ptr的计数并没有增加。也就是说,这个时候,p和q都认为自己是Test指针的唯一拥有者,这两个shared_ptr在计数为0的时候,都会调用一次Test对象的析构函数,所以会出问题。

那么为什么会这样呢?给一个shared_ptr传递一个this指针难道不能引起shared_ptr的计数吗?

答案是:对的,shared_ptr根本认不得你传进来的指针变量是不是之前已经传过。</font>

看这样的代码:

int main()
{
    Test* test = new Test();
    shared_ptr<Test> p(test);
    shared_ptr<Test> q(test);
    std::cout << "p.use_count(): " << p.use_count() << std::endl;
    std::cout << "q.use_count(): " << q.use_count() << std::endl;
    return 0;
}

运行后,程序输出:

p.use_count(): 1
q.use_count(): 1
Test Destructor.
Test Destructor.

也证明了刚刚的论述:shared_ptr根本认不得你传进来的指针变量是不是之前已经传过。

事实上,类对象是由外部函数通过某种机制分配的,而且一经分配立即交给 shared_ptr管理,而且以后凡是需要共享使用类对象的地方,必须使用这个 shared_ptr当作右值来构造产生或者拷贝产生(shared_ptr类中定义了赋值运算符函数和拷贝构造函数)另一个shared_ptr ,从而达到共享使用的目的。

解释了上述现象后,现在的问题就变为了:如何在类对象(Test)内部中获得一个指向当前对象的shared_ptr 对象?(之前证明,在类的内部直接返回this指针,或者返回return shared_ptr pTest(this);)不行,因为shared_ptr根本认不得你传过来的指针变量是不是之前已经传过,你本意传个shared_ptr pTest(this)是想这个对象use_count=2,就算this对象生命周期结束,但是也不delete,因为你异步回来还要用对象里面的东西。) 如果我们能够做到这一点,直接将这个shared_ptr对象返回,就不会造成新建的shared_ptr的问题了。

下面来看看enable_shared_from_this类的威力。
enable_shared_from_this 是一个以其派生类为模板类型参数的基类模板,继承它,派生类的this指针就能变成一个 shared_ptr。
有如下代码:

#include <iostream>
#include <memory>

class Test : public std::enable_shared_from_this<Test>        //改进1
{
public:
    //析构函数
    ~Test() { std::cout << "Test Destructor." << std::endl; }
    //获取指向当前对象的指针
    std::shared_ptr<Test> GetObject()
    {
        return shared_from_this();      //改进2
    }
};
int main(int argc, char *argv[])
{
    {
        std::shared_ptr<Test> p( new Test( ));
        std::shared_ptr<Test> q = p->GetObject();
        std::cout << "p.use_count(): " << p.use_count() << std::endl;
        std::cout << "q.use_count(): " << q.use_count() << std::endl;
    }
    return 0;
}

运行后,程序输出:

	p.use_count(): 2
	q.use_count(): 2
	Test Destructor.

可以看到,问题解决了!只有一次new对象,那么释放的时候也就一次,不会出现两次而引起程序崩溃。但是要说明的是,这里举的例子是两个shared_ptr p和q都离开作用域时,Test对象才调用了析构函数,真正释放对象。但是我们在异步函数里面其目的是:

struct connection : boost::enable_shared_from_this<connection> {
	typedef boost::shared_ptr<connection> ptr;
	void start(ip::tcp::endpoint ep) {
        sock_.async_connect(ep, boost::bind(&connection::on_connect, shared_from_this(), _1));
    }
};

int main(int argc, char* argv[]) {
    ip::tcp::endpoint ep( ip::address::from_string("127.0.0.1"), 8001);
    connection::ptr(new connection)->start(ep);
} 

1、这里的connection::ptr(new connection)->start(ep);能否用普通new的指针,而没有被shared_ptr托管的指针? 答案是不能,原因见后面说明2。
2、这段server端的代码,每当有不同client连过来,就会触发on_connect回调函数执行。在所有异步调用中,我们传递一个boost::bind仿函数当作参数。这个仿函数内部包含了一个智能指针,指向connection实例。只要有一个异步操作等待时,Boost.Asio就会保存boost::bind仿函数的拷贝,这个拷贝保存了指向连接实例的一个智能指针,从而保证connection实例保持活动。问题解决!

接着来看看enable_shared_from_this 是如何工作的,以下是它的源码:

template<class T> class enable_shared_from_this
{
protected:

    BOOST_CONSTEXPR enable_shared_from_this() BOOST_SP_NOEXCEPT { }

    BOOST_CONSTEXPR enable_shared_from_this(enable_shared_from_this const &) BOOST_SP_NOEXCEPT { }

    enable_shared_from_this & operator=(enable_shared_from_this const &) BOOST_SP_NOEXCEPT {
        return *this;
    }

    ~enable_shared_from_this() BOOST_SP_NOEXCEPT // ~weak_ptr<T> newer throws, so this call also must not throw
    { }

public:

    shared_ptr<T> shared_from_this() {
        shared_ptr<T> p( weak_this_ );
        BOOST_ASSERT( p.get() == this );
        return p;
    }

    shared_ptr<T const> shared_from_this() const {
        shared_ptr<T const> p( weak_this_ );
        BOOST_ASSERT( p.get() == this );
        return p;
    }

    weak_ptr<T> weak_from_this() BOOST_SP_NOEXCEPT
    {
        return weak_this_;
    }

    weak_ptr<T const> weak_from_this() const BOOST_SP_NOEXCEPT
    {
        return weak_this_;
    }

public: // actually private, but avoids compiler template friendship issues

    // Note: invoked automatically by shared_ptr; do not call
    template<class X, class Y> void _internal_accept_owner( shared_ptr<X> const * ppx, Y * py ) const BOOST_SP_NOEXCEPT
    {
        if( weak_this_.expired() )
        {
            weak_this_ = shared_ptr<T>( *ppx, py );
        }
    }

private:

    mutable weak_ptr<T> weak_this_;
};

} // namespace boost

#endif  // #ifndef BOOST_SMART_PTR_ENABLE_SHARED_FROM_THIS_HPP_INCLUDED

其中shared_from_this()函数的实现为:

    shared_ptr<T> shared_from_this()
    {
        shared_ptr<T> p( weak_this_ );
        BOOST_ASSERT( p.get() == this );
        return p;
    }

可以看见,这个函数使用了weak_ptr对象(weak_this)来构造一个shared_ptr对象,然后将shared_ptr对象返回。注意这个weak_ptr是实例对象的一个成员变量,所以对于一个对象来说,它一直是同一个,每次调用shared_from_this()时,就会根据weak_ptr来构造一个临时shared_ptr对象。

也许看到这里会产生疑问,这里的shared_ptr也是一个临时对象,和前面有什么区别?还有,为什么enable_shared_from_this 不直接保存一个 shared_ptr 成员?

对于第一个问题,这里的每一个shared_ptr都是根据weak_ptr来构造的,而每次构造shared_ptr的时候,使用的参数是一样的,所以这里根据相同的weak_ptr来构造多个临时shared_ptr等价于用一个shared_ptr来做拷贝。(你在不同地方调用shared_form_this时,那管理的始终是一个对象)(PS:在shared_ptr类中,是有使用weak_ptr对象来构造shared_ptr对象的构造函数的:

template<class Y>
explicit shared_ptr( weak_ptr<Y> const & r ): pn( r.pn )

对于第二个问题,假设我在类里储存了一个指向自身的shared_ptr,那么这个 shared_ptr的计数最少都会是1,也就是说,这个对象将永远不能析构,所以这种做法是不可取的。

在enable_shared_from_this类中,没有看到给成员变量weak_this_初始化赋值的地方,那究竟是如何保证weak_this_拥有着Test类对象的指针呢?

首先我们生成类T时,会依次调用enable_shared_from_this类的构造函数(定义为protected),以及类Test的构造函数。在调用enable_shared_from_this的构造函数时,会初始化定义在enable_shared_from_this中的私有成员变量weak_this_(调用其默认构造函数),这时的weak_this_是无效的(或者说不指向任何对象)。

接着,当外部程序把指向类Test对象的指针作为初始化参数来初始化一个shared_ptr(boost::shared_ptr p( new Test( ));)。

现在来看看 shared_ptr是如何初始化的,shared_ptr 定义了如下构造函数:

template<class Y>
    explicit shared_ptr( Y * p ): px( p ), pn( p ) 
    {
        boost::detail::sp_enable_shared_from_this( this, p, p );
    }

里面调用了 boost::detail::sp_enable_shared_from_this :

template< class X, class Y, class T >
 inline void sp_enable_shared_from_this( boost::shared_ptr<X> const * ppx,
 Y const * py, boost::enable_shared_from_this< T > const * pe )
{
    if( pe != 0 )
    {
        pe->_internal_accept_owner( ppx, const_cast< Y* >( py ) );
    }
}

里面又调用了enable_shared_from_this 的 _internal_accept_owner :

template<class X, class Y> void _internal_accept_owner( shared_ptr<X> const * ppx, Y * py ) const
    {
        if( weak_this_.expired() )
        {
            weak_this_ = shared_ptr<T>( *ppx, py );
        }
    }

而在这里,对enable_shared_from_this 类的成员weak_this_进行拷贝赋值,使得weak_this_作为类对象 shared_ptr 的一个观察者。
这时,当类对象本身需要自身的shared_ptr时,就可以从这个weak_ptr来生成一个了:

shared_ptr<T> shared_from_this()
    {
        shared_ptr<T> p( weak_this_ );
        BOOST_ASSERT( p.get() == this );
        return p;
    }

从上面的说明来看,需要小心的是shared_from_this()仅在shared_ptr的构造函数被调用之后才能使用,原因是enable_shared_from_this::weak_this_并不在构造函数中设置,而是在shared_ptr的构造函数中设置。

说明1:

所以,如下代码是错误的:

class D:public boost::enable_shared_from_this<D>
{
public:
    D()
    {
        boost::shared_ptr<D> p=shared_from_this();
    }
};

原因是在D的构造函数中虽然可以保证enable_shared_from_this的构造函数被调用,但weak_this_是无效的(还还没被接管)。

说明2:

如下代码也是错误的:

class D:public boost::enable_shared_from_this<D>
{
public:
    void func()
    {
        boost::shared_ptr<D> p=shared_from_this();
    }
};
void main()
{
    D d;
    d.func();
}

原因同上。 总结为:不要试图对一个没有被shared_ptr接管的类对象调用shared_from_this(),不然会产生未定义行为的错误。

基于boost.Asio的异步socket例子:
https://github.com/goyas/recipes/tree/master/socket_benchmark

参考文献:

https://www.jianshu.com/p/4444923d79bd
https://blog.csdn.net/veghlreywg/article/details/89743605
https://www.cnblogs.com/codingmengmeng/p/9123874.html
https://www.cnblogs.com/yang-wen/p/8573269.html

]]>
为什么需要异步编程 2019-11-30T16:15:00+00:00 goyas http://goyas.github.io/async-program 一、背景

Reactor和Proactor模型一文中讲到,Reactor模型提供了一个比较理想的I/O编程框架,让程序更有结构,用户使用起来更加方便,比裸API调用开发效率要高。另外一方面,如果希望每个事件通知之后,做的事情能有机会被代理到某个线程里面去单独运行,而线程完成的状态又能通知回主任务,那么”异步”的机制就必须被引入。本文以boost.Asio库(其设计模式为Proactor)为基础,讲解为什么需要异步编程以及异步编程的实现。

二、举例


跑步

设想你是一位体育老师,需要测验100位同学的400米成绩。你当然不会让100位同学一起起跑,因为当同学们返回终点时,你根本来不及掐表记录各位同学的成绩。

如果你每次让一位同学起跑并等待他回到终点你记下成绩后再让下一位起跑,直到所有同学都跑完。恭喜你,你已经掌握了同步阻塞模式。你设计了一个函数,传入参数是学生号和起跑时间,返回值是到达终点的时间。你调用该函数100次,就能完成这次测验任务。这个函数是同步的,因为只要你调用它,就能得到结果;这个函数也是阻塞的,因为你一旦调用它,就必须等待,直到它给你结果,不能去干其他事情。

如果你一边每隔10秒让一位同学起跑,直到所有同学出发完毕;另一边每有一个同学回到终点就记录成绩,直到所有同学都跑完。恭喜你,你已经掌握了异步非阻塞模式。你设计了两个函数,其中一个函数记录起跑时间和学生号,该函数你会主动调用100次;另一个函数记录到达时间和学生号,该函数是一个事件驱动的callback函数,当有同学到达终点时,你会被动调用。你主动调用的函数是异步的,因为你调用它,它并不会告诉你结果;这个函数也是非阻塞的,因为你一旦调用它,它就马上返回,你不用等待就可以再次调用它。但仅仅将这个函数调用100次,你并没有完成你的测验任务,你还需要被动等待调用另一个函数100次。

当然,你马上就会意识到,同步阻塞模式的效率明显低于异步非阻塞模式。那么,谁还会使用同步阻塞模式呢?不错,异步模式效率高,但更麻烦,你一边要记录起跑同学的数据,一边要记录到达同学的数据,而且同学们回到终点的次序与起跑的次序并不相同,所以你还要不停地在你的成绩册上查找学生号。忙乱之中你往往会张冠李戴。你可能会想出更聪明的办法:你带了很多块秒表,让同学们分组互相测验。恭喜你!你已经掌握了多线程同步模式

每个拿秒表的同学都可以独立调用你的同步函数,这样既不容易出错,效率也大大提高,只要秒表足够多,同步的效率也能达到甚至超过异步。

可以理解,你现的问题可能是:既然多线程同步既快又好,异步模式还有存在的必要吗?

很遗憾,异步模式依然非常重要,因为在很多情况下,你拿不出很多秒表。你需要通信的对端系统可能只允许你建立一个SOCKET连接,很多金融、电信行业的大型业务系统都如此要求。

三、背景知识介绍


3.1 同步函数VS异步函数

以下部分主要来自于:https://www.cnblogs.com/balingybj/p/4780442.html
依据微软的MSDN上的解说:
(1)、同步函数:当一个函数是同步执行时,那么当该函数被调用时不会立即返回,直到该函数所要做的事情全都做完了才返回。
(2)、异步函数:如果一个异步函数被调用时,该函数会立即返回尽管该函数规定的操作任务还没有完成。
(3)、在一个线程中分别调用上述两种函数会对调用线程有何影响呢?

  • 当一个线程调用一个同步函数时(例如:该函数用于完成写文件任务),如果该函数没有立即完成规定的操作,则该操作会导致该调用线程的挂起(将CPU的使用权交给系统,让系统分配给其他线程使用),直到该同步函数规定的操作完成才返回,最终才能导致该调用线程被重新调度。
  • 当一个线程调用的是一个异步函数(例如:该函数用于完成写文件任务),该函数会立即返回尽管其规定的任务还没有完成,这样线程就会执行异步函数的下一条语句,而不会被挂起。那么该异步函数所规定的工作是如何被完成的呢?当然是通过另外一个线程完成的了啊;那么新的线程是哪里来的呢?可能是在异步函数中新创建的一个线程也可能是系统中已经准备好的线程。

(4)、一个调用了异步函数的线程如何与异步函数的执行结果同步呢?

  • 为了解决该问题,调用线程需要使用“等待函数”来确定该异步函数何时完成了规定的任务。因此在线程调用异步函数之后立即调用一个“等待函数”挂起调用线程,一直等到异步函数执行完其所有的操作之后,再执行线程中的下一条指令。

我们是否已经发现了一个有趣的地方呢?!就是我们可以使用等待函数将一个异步执行的函数封装成一个同步函数。

3.2 同步调用VS异步调用

操作系统发展到今天已经十分精巧,线程就是其中一个杰作。操作系统把 CPU 处理时间划分成许多短暂时间片,在时间 T1 执行一个线程的指令,到时间 T2 又执行下一线程的指令,各线程轮流执行,结果好象是所有线程在并肩前进。这样,编程时可以创建多个线程,在同一期间执行,各线程可以“并行”完成不同的任务。

在单线程方式下,计算机是一台严格意义上的冯·诺依曼式机器,一段代码调用另一段代码时,只能采用同步调用,必须等待这段代码执行完返回结果后,调用方才能继续往下执行。有了多线程的支持,可以采用异步调用,调用方和被调方可以属于两个不同的线程,调用方启动被调方线程后,不等对方返回结果就继续执行后续代码。被调方执行完毕后,通过某种手段通知调用方:结果已经出来,请酌情处理。  

计算机中有些处理比较耗时。调用这种处理代码时,调用方如果站在那里苦苦等待,会严重影响程序性能。例如,某个程序启动后如果需要打开文件读出其中的数据,再根据这些数据进行一系列初始化处理,程序主窗口将迟迟不能显示,让用户感到这个程序怎么等半天也不出来,太差劲了。借助异步调用可以把问题轻松化解:把整个初始化处理放进一个单独线程,主线程启动此线程后接着往下走,让主窗口瞬间显示出来。等用户盯着窗口犯呆时,初始化处理就在背后悄悄完成了。程序开始稳定运行以后,还可以继续使用这种技巧改善人机交互的瞬时反应。用户点击鼠标时,所激发的操作如果较费时,再点击鼠标将不会立即反应,整个程序显得很沉重。借助异步调用处理费时的操作,让主线程随时恭候下一条消息,用户点击鼠标时感到轻松快捷,肯定会对软件产生好感。

异步调用用来处理从外部输入的数据特别有效。假如计算机需要从一台低速设备索取数据,然后是一段冗长的数据处理过程,采用同步调用显然很不合算:计算机先向外部设备发出请求,然后等待数据输入;而外部设备向计算机发送数据后,也要等待计算机完成数据处理后再发出下一条数据请求。双方都有一段等待期,拉长了整个处理过程。其实,计算机可以在处理数据之前先发出下一条数据请求,然后立即去处理数据。如果数据处理比数据采集快,要等待的只有计算机,外部设备可以连续不停地采集数据。如果计算机同时连接多台输入设备,可以轮流向各台设备发出数据请求,并随时处理每台设备发来的数据,整个系统可以保持连续高速运转。编程的关键是把数据索取代码和数据处理代码分别归属两个不同的线程。数据处理代码调用一个数据请求异步函数,然后径自处理手头的数据。待下一组数据到来后,数据处理线程将收到通知,结束 wait 状态,发出下一条数据请求,然后继续处理数据。

异步调用时,调用方不等被调方返回结果就转身离去,因此必须有一种机制让被调方有了结果时能通知调用方。在同一进程中有很多手段可以利用,笔者常用的手段是回调、event 对象和消息。

回调:回调方式很简单:调用异步函数时在参数中放入一个函数地址,异步函数保存此地址,待有了结果后回调此函数便可以向调用方发出通知。如果把异步函数包装进一个对象中,可以用事件取代回调函数地址,通过事件处理例程向调用方发通知。   

event : event 是 Windows 系统提供的一个常用同步对象,以在异步处理中对齐不同线程之间的步点。如果调用方暂时无事可做,可以调用 wait 函数等在那里,此时 event 处于 nonsignaled 状态。当被调方出来结果之后,把 event 对象置于 signaled 状态,wait 函数便自动结束等待,使调用方重新动作起来,从被调方取出处理结果。这种方式比回调方式要复杂一些,速度也相对较慢,但有很大的灵活性,可以搞出很多花样以适应比较复杂的处理系统。

消息:借助 Windows 消息发通知是个不错的选择,既简单又安全。程序中定义一个用户消息,并由调用方准备好消息处理例程。被调方出来结果之后立即向调用方发送此消息,并通过 WParam 和 LParam 这两个参数传送结果。消息总是与窗口 handle 关联,因此调用方必须借助一个窗口才能接收消息,这是其不方便之处。另外,通过消息联络会影响速度,需要高速处理时回调方式更有优势。

如果调用方和被调方分属两个不同的进程,由于内存空间的隔阂,一般是采用 Windows 消息发通知比较简单可靠,被调方可以借助消息本身向调用方传送数据。event 对象也可以通过名称在不同进程间共享,但只能发通知,本身无法传送数据,需要借助 Windows 消息和 FileMapping 等内存共享手段或借助 MailSlot 和 Pipe 等通信手段。

如果你的服务端的客户端数量多,你的服务端就采用异步的,但是你的客户端可以用同步的,客户端一般功能比较单一,收到数据后才能执行下面的工作,所以弄成同步的在那等。

3.3 同步异步与阻塞和非阻塞

同步异步指的是通信模式,而阻塞和非阻塞指的是在接收和发送时是否等待动作完成才返回。

首先是通信的同步,主要是指客户端在发送请求后,必须得在服务端有回应后才发送下一个请求。所以这个时候的所有请求将会在服务端得到同步。
其次是通信的异步,指客户端在发送请求后,不必等待服务端的回应就可以发送下一个请求,这样对于所有的请求动作来说将会在服务端得到异步,这条请求的链路就象是一个请求队列,所有的动作在这里不会得到同步的。

阻塞和非阻塞只是应用在请求的读取和发送。
在实现过程中,如果服务端是异步的话,客户端也是异步的话,通信效率会很高,但如果服务端在请求的返回时也是返回给请求的链路时,客户端是可以同步的,这种情况下,服务端是兼容同步和异步的。相反,如果客户端是异步而服务端是同步的也不会有问题,只是处理效率低了些。

举个打电话的例子

阻塞 block 是指,你拨通某人的电话,但是此人不在,于是你拿着电话等他回来,其间不能再用电话。同步大概和阻塞差不多。
非阻塞 nonblock 是指,你拨通某人的电话,但是此人不在,于是你挂断电话,待会儿再打。至于到时候他回来没有,只有打了电话才知道。即所谓的“轮询 / poll”。
异步是指,你拨通某人的电话,但是此人不在,于是你叫接电话的人告诉那人(leave a message),回来后给你打电话(call back)。

一、同步阻塞模式
在这个模式中,用户空间的应用程序执行一个系统调用,并阻塞,直到系统调用完成为止(数据传输完成或发生错误)。

二、同步非阻塞模式
同步阻塞 I/O 的一种效率稍低的。非阻塞的实现是 I/O 命令可能并不会立即满足,需要应用程序调用许多次来等待操作完成。这可能效率不高,因为在很多情况下,当内核执行这个命令时,应用程序必须要进行忙碌等待,直到数据可用为止,或者试图执行其他工作。因为数据在内核中变为可用到用户调用 read 返回数据之间存在一定的间隔,这会导致整体数据吞吐量的降低。但异步非阻塞由于是多线程,效率还是高。

/* create the connection by socket 
* means that connect "sockfd" to "server_addr"
* 同步阻塞模式 
*/
if (connect(sockfd, (struct sockaddr *)&server_addr, sizeof(struct sockaddr)) == -1)
{
perror("connect");
exit(1);
}

/* 同步非阻塞模式 */
while (send(sockfd, snd_buf, sizeof(snd_buf), MSG_DONTWAIT) == -1)
{
sleep(1);
printf("sleep\n");
}

四、异步的需求


前面说了那么多,现在终于可以回到我们的正题,介绍异步编程了。就像之前所说的,同步编程比异步编程简单很多。这是因为,线性的思考是很简单的(调用A,调用A结束,调用B,调用B结束,然后继续,这是以事件处理的方式来思考)。后面你会碰到这种情况,比如:五件事情,你不知道它们执行的顺序,也不知道他们是否会执行!这部分主要参考:https://mmoaay.gitbooks.io/boost-asio-cpp-network-programming-chinese/content/Chapter2.html

尽管异步编程更难,但是你会更倾向于选择使用它,比如:写一个需要处理很多并发访问的服务端。并发访问越多,异步编程就比同步编程越简单。

假设:你有一个需要处理1000个并发访问的应用,从客户端发给服务端的每个信息都会再返回给客户端,以‘\n’结尾。

同步方式的代码,1个线程:

using namespace boost::asio;
struct client {
    ip::tcp::socket sock;
    char buff[1024]; // 每个信息最多这么大
    int already_read; // 你已经读了多少
};
std::vector<client> clients;
void handle_clients() {
    while ( true)
        for ( int i = 0; i < clients.size(); ++i)
            if ( clients[i].sock.available() ) on_read(clients[i]);
}
void on_read(client & c) {
    int to_read = std::min( 1024 - c.already_read, c.sock.available());
    c.sock.read_some( buffer(c.buff + c.already_read, to_read));
    c.already_read += to_read;
    if ( std::find(c.buff, c.buff + c.already_read, '\n') < c.buff + c.already_read) {
        int pos = std::find(c.buff, c.buff + c.already_read, '\n') - c.buff;
        std::string msg(c.buff, c.buff + pos);
        std::copy(c.buff + pos, c.buff + 1024, c.buff);
        c.already_read -= pos;
        on_read_msg(c, msg);
    }
}
void on_read_msg(client & c, const std::string & msg) {
    // 分析消息,然后返回
    if ( msg == "request_login")
        c.sock.write( "request_ok\n");
    else if ...
}

有一种情况是在任何服务端(和任何基于网络的应用)都需要避免的,就是代码无响应的情况。在我们的例子里,我们需要handle_clients()方法尽可能少的阻塞。如果方法在某个点上阻塞,任何进来的信息都需要等待方法解除阻塞才能被处理。

为了保持响应,只在一个套接字有数据的时候我们才读,也就是说,if ( clients[i].sock.available() ) on_read(clients[i])。在on_read时,我们只读当前可用的;调用read_until(c.sock, buffer(…), ‘\n’)会是一个非常糟糕的选择,因为直到我们从一个指定的客户端读取了完整的消息之前,它都是阻塞的(我们永远不知道它什么时候会读取到完整的消息)

这里的瓶颈就是on_read_msg()方法;当它执行时,所有进来的消息都在等待。一个良好的on_read_msg()方法实现会保证这种情况基本不会发生,但是它还是会发生(有时候向一个套接字写入数据,缓冲区满了时,它会被阻塞) 同步方式的代码,10个线程

using namespace boost::asio;
struct client {
   // ... 和之前一样
    bool set_reading() {
        boost::mutex::scoped_lock lk(cs_);
        if ( is_reading_) return false; // 已经在读取
        else { is_reading_ = true; return true; }
    }
    void unset_reading() {
        boost::mutex::scoped_lock lk(cs_);
        is_reading_ = false;
    }
private:
    boost::mutex cs_;
    bool is_reading_;
};
std::vector<client> clients;
void handle_clients() {
    for ( int i = 0; i < 10; ++i)
        boost::thread( handle_clients_thread);
}
void handle_clients_thread() {
    while ( true)
        for ( int i = 0; i < clients.size(); ++i)
            if ( clients[i].sock.available() )
                if ( clients[i].set_reading()) {
                    on_read(clients[i]);
                    clients[i].unset_reading();
                }
}
void on_read(client & c) {
    // 和之前一样
}
void on_read_msg(client & c, const std::string & msg) {
    // 和之前一样
}

为了使用多线程,我们需要对线程进行同步,这就是set_reading()set_unreading()所做的。set_reading()方法非常重要,比如你想要一步实现“判断是否在读取然后标记为读取中”。但这是有两步的(“判断是否在读取”和“标记为读取中”),你可能会有两个线程同时为一个客户端判断是否在读取,然后你会有两个线程同时为一个客户端调用on_read,结果就是数据冲突甚至导致应用崩溃。

你会发现代码变得极其复杂。

同步编程有第三个选择,就是为每个连接开辟一个线程。但是当并发的线程增加时,这就成了一种灾难性的情况。

然后,让我们来看异步编程。我们不断地异步读取。当一个客户端请求某些东西时,on_read被调用,然后回应,然后等待下一个请求(然后开始另外一个异步的read操作)。

异步方式的代码,10个线程

using namespace boost::asio;
io_service service;
struct client {
    ip::tcp::socket sock;
    streambuf buff; // 从客户端取回结果
}
std::vector<client> clients;
void handle_clients() {
    for ( int i = 0; i < clients.size(); ++i)
        async_read_until(clients[i].sock, clients[i].buff, '\n', boost::bind(on_read, clients[i], _1, _2));
    for ( int i = 0; i < 10; ++i)
        boost::thread(handle_clients_thread);
}
void handle_clients_thread() {
    service.run();
}
void on_read(client & c, const error_code & err, size_t read_bytes) {
    std::istream in(&c.buff);
    std::string msg;
    std::getline(in, msg);
    if ( msg == "request_login")
        c.sock.async_write( "request_ok\n", on_write);
    else if ...
    ...
    // 等待同一个客户端下一个读取操作
    async_read_until(c.sock, c.buff, '\n', boost::bind(on_read, c, _1, _2));
}

发现代码变得有多简单了吧?client结构里面只有两个成员,handle_clients()仅仅调用了async_read_until,然后它创建了10个线程,每个线程都调用service.run()。这些线程会处理所有来自客户端的异步read操作,然后分发所有向客户端的异步write操作。另外需要注意的一件事情是:on_read()一直在为下一次异步read操作做准备(看最后一行代码)。

4.1 持续运行

再一次说明,如果有等待执行的操作,run()会一直执行,直到你手动调用io_service::stop()。为了保证io_service一直执行,通常你添加一个或者多个异步操作,然后在它们被执行时,你继续一直不停地添加异步操作,比如下面代码:

using namespace boost::asio;
io_service service;
ip::tcp::socket sock(service);
char buff_read[1024], buff_write[1024] = "ok";
void on_read(const boost::system::error_code &err, std::size_t bytes);
void on_write(const boost::system::error_code &err, std::size_t bytes)
{
    sock.async_read_some(buffer(buff_read), on_read);
}
void on_read(const boost::system::error_code &err, std::size_t bytes)
{
    // ... 处理读取操作 ...
    sock.async_write_some(buffer(buff_write,3), on_write);
}
void on_connect(const boost::system::error_code &err) {
    sock.async_read_some(buffer(buff_read), on_read);
}
int main(int argc, char* argv[]) {
    ip::tcp::endpoint ep( ip::address::from_string("127.0.0.1"), 2001);
    sock.async_connect(ep, on_connect);
    service.run();
}
  1. service.run()被调用时,有一个异步操作在等待。
  2. 当socket连接到服务端时,on_connect被调用了,它会添加一个异步操作。
  3. on_connect结束时,我们会留下一个等待的操作(read)。
  4. on_read被调用时,我们写入一个回应,这又添加了另外一个等待的操作。
  5. on_read结束时,我们会留下一个等待的操作(write)。
  6. on_write操作被调用时,我们从服务端读取另外一个消息,这也添加了另外一个等待的操作。
  7. on_write结束时,我们有一个等待的操作(read)。
  8. 然后一直继续循环下去,直到我们关闭这个应用。

4.2 保持活动

假设你需要做下面的操作:

io_service service;
ip::tcp::socket sock(service);
char buff[512];
...
read(sock, buffer(buff));

在这个例子中,sockbuff的存在时间都必须比read()调用的时间要长。也就是说,在调用read()返回之前,它们都必须有效。这就是你所期望的;你传给一个方法的所有参数在方法内部都必须有效。当我们采用异步方式时,事情会变得比较复杂。

io_service service;
ip::tcp::socket sock(service);
char buff[512];
void on_read(const boost::system::error_code &, size_t) {}
...
async_read(sock, buffer(buff), on_read);

在这个例子中,sockbuff的存在时间都必须比read()操作本身时间要长,但是read操作持续的时间我们是不知道的,因为它是异步的。

当使用socket缓冲区的时候,你会有一个buffer实例在异步调用时一直存在(使用boost::shared_array<>)。在这里,我们可以使用同样的方式,通过创建一个类并在其内部管理socket和它的读写缓冲区。然后,对于所有的异步操作,传递一个包含智能指针的boost::bind仿函数给它:

using namespace boost::asio;
io_service service;
struct connection : boost::enable_shared_from_this<connection> {
    typedef boost::system::error_code error_code;
    typedef boost::shared_ptr<connection> ptr;
    connection() : sock_(service), started_(true) {}
    void start(ip::tcp::endpoint ep) {
        sock_.async_connect(ep, boost::bind(&connection::on_connect, shared_from_this(), _1));
    }
    void stop() {
        if ( !started_) return;
        started_ = false;
        sock_.close();
    }
    bool started() { return started_; }
private:
    void on_connect(const error_code & err) {
        // 这里你决定用这个连接做什么: 读取或者写入
        if ( !err) do_read();
        else stop();
    }
    void on_read(const error_code & err, size_t bytes) {
        if ( !started() ) return;
        std::string msg(read_buffer_, bytes);
        if ( msg == "can_login") do_write("access_data");
        else if ( msg.find("data ") == 0) process_data(msg);
        else if ( msg == "login_fail") stop();
    }
    void on_write(const error_code & err, size_t bytes) {
        do_read(); 
    }
    void do_read() {
        sock_.async_read_some(buffer(read_buffer_), boost::bind(&connection::on_read, shared_from_this(),   _1, _2)); 
    }
    void do_write(const std::string & msg) {
        if ( !started() ) return;
        // 注意: 因为在做另外一个async_read操作之前你想要发送多个消息, 
        // 所以你需要多个写入buffer
        std::copy(msg.begin(), msg.end(), write_buffer_);
        sock_.async_write_some(buffer(write_buffer_, msg.size()), boost::bind(&connection::on_write, shared_from_this(), _1, _2)); 
    }

    void process_data(const std::string & msg) {
        // 处理服务端来的内容,然后启动另外一个写入操作
    }
private:
    ip::tcp::socket sock_;
    enum { max_msg = 1024 };
    char read_buffer_[max_msg];
    char write_buffer_[max_msg];
    bool started_;
};

int main(int argc, char* argv[]) {
    ip::tcp::endpoint ep( ip::address::from_string("127.0.0.1"), 8001);
    connection::ptr(new connection)->start(ep);
} 

在所有异步调用中,我们传递一个boost::bind仿函数当作参数。这个仿函数内部包含了一个智能指针,指向connection实例。只要有一个异步操作等待时,Boost.Asio就会保存boost::bind仿函数的拷贝,这个拷贝保存了指向连接实例的一个智能指针,从而保证connection实例保持活动。问题解决!

当然,connection类仅仅是一个框架类;你需要根据你的需求对它进行调整(它看起来会和当前服务端例子的情况相当不同)。

你需要注意的是创建一个新的连接是相当简单的:connection::ptr(new connection)- >start(ep)。这个方法启动了到服务端的(异步)连接。当你需要关闭这个连接时,调用stop()

当实例被启动时(start()),它会等待客户端的连接。当连接发生时。on_connect()被调用。如果没有错误发生,它启动一个read操作(do_read())。当read操作结束时,你就可以解析这个消息;当然你应用的on_read()看起来会各种各样。而当你写回一个消息时,你需要把它拷贝到缓冲区,然后像我在do_write()方法中所做的一样将其发送出去,因为这个缓冲区同样需要在这个异步写操作中一直存活。最后需要注意的一点——当写回时,你需要指定写入的数量,否则,整个缓冲区都会被发送出去。

总结

网络api实际上要繁杂得多,这个章节只是做为一个参考,当你在实现自己的网络应用时可以回过头来看看。

Boost.Asio实现了端点的概念,你可以认为是IP和端口。如果你不知道准确的IP,你可以使用resolver对象将主机名,例如www.yahoo.com转换为一个或多个IP地址。

我们也可以看到API的核心——socket类。Boost.Asio提供了TCP、UDPICMP的实现。而且你还可以用你自己的协议来对它进行扩展;当然,这个工作不适合缺乏勇气的人。

异步编程是刚需。你应该已经明白为什么有时候需要用到它,尤其在写服务端的时候。调用service.run()来实现异步循环就已经可以让你很满足,但是有时候你需要更进一步,尝试使用run_one()、poll()或者poll_one()

当实现异步时,你可以异步执行你自己的方法;使用service.post()或者service.dispatch()

最后,为了使socket和缓冲区(read或者write)在整个异步操作的生命周期中一直活动,我们需要采取特殊的防护措施。你的连接类需要继承自enabled_shared_from_this,然后在内部保存它需要的缓冲区,而且每次异步调用都要传递一个智能指针给this操作。

]]>
Reactor和Proactor模型 2019-11-30T11:11:00+00:00 goyas http://goyas.github.io/reactor-proactor 一、背景

前面介绍了I/O多路复用模型,那有了I/O复用,有了epoll已经可以使服务器并发几十万连接的同时,还能维持比较高的TPS,难道还不够吗?比如现在在使用epoll的时候一般都是起个任务,不断的去巡检事件,然后通知处理,而比较理想的方式是最好能以一种回调的机制,提供一个编程框架,让程序更有结构些,另一方面,如果希望每个事件通知之后,做的事情能有机会被代理到某个线程里面去单独运行,而线程完成的状态又能通知回主任务,那么”异步”的进制就必须被引入。所以这个章节主要介绍下”编程框架”。

二、Reactor模型

Reactor模式是处理并发I/O比较常见的一种模式,用于同步I/O,中心思想是将所有要处理的I/O事件注册到一个中心I/O多路复用器上,同时主线程/进程阻塞在多路复用器上;一旦有I/O事件到来或是准备就绪(文件描述符或socket可读、写),多路复用器返回并将事先注册的相应I/O事件分发到对应的处理器中。

Reactor是一种事件驱动机制,和普通函数调用的不同之处在于:应用程序不是主动的调用某个API完成处理,而是恰恰相反,Reactor逆置了事件处理流程,应用程序需要提供相应的接口并注册到Reactor上,如果相应的事件发生,Reactor将主动调用应用程序注册的接口,这些接口又称为“回调函数”。用“好莱坞原则”来形容Reactor再合适不过了:不要打电话给我们,我们会打电话通知你。

Reactor模式与Observer模式在某些方面极为相似:当一个主体发生改变时,所有依属体都得到通知。不过,观察者模式与单个事件源关联,而反应器模式则与多个事件源关联 。

在Reactor模式中,有5个关键的参与者:

  1. 描述符(handle):由操作系统提供的资源,用于识别每一个事件,如Socket描述符、文件描述符、信号的值等。在Linux中,它用一个整数来表示。事件可以来自外部,如来自客户端的连接请求、数据等。事件也可以来自内部,如信号、定时器事件。
  2. 同步事件多路分离器(event demultiplexer):事件的到来是随机的、异步的,无法预知程序何时收到一个客户连接请求或收到一个信号。所以程序要循环等待并处理事件,这就是事件循环。在事件循环中,等待事件一般使用I/O复用技术实现。在linux系统上一般是select、poll、epoll等系统调用,用来等待一个或多个事件的发生。I/O框架库一般将各种I/O复用系统调用封装成统一的接口,称为事件多路分离器。调用者会被阻塞,直到分离器分离的描述符集上有事件发生。这点可以参考前面的 I/O多路复用模型
  3. 事件处理器(event handler):I/O框架库提供的事件处理器通常是由一个或多个模板函数组成的接口。这些模板函数描述了和应用程序相关的对某个事件的操作,用户需要继承它来实现自己的事件处理器,即具体事件处理器。因此,事件处理器中的回调函数一般声明为虚函数,以支持用户拓展。
  4. 具体的事件处理器(concrete event handler):是事件处理器接口的实现。它实现了应用程序提供的某个服务。每个具体的事件处理器总和一个描述符相关。它使用描述符来识别事件、识别应用程序提供的服务。
  5. Reactor 管理器(reactor):定义了一些接口,用于应用程序控制事件调度,以及应用程序注册、删除事件处理器和相关的描述符。它是事件处理器的调度核心。 Reactor管理器使用同步事件分离器来等待事件的发生。一旦事件发生,Reactor管理器先是分离每个事件,然后调度事件处理器,最后调用相关的模 板函数来处理这个事件。

2.1 应用场景

  • 场景:长途客车在路途上,有人上车有人下车,但是乘客总是希望能够在客车上得到休息。
  • 传统做法:每隔一段时间(或每一个站),司机或售票员对每一个乘客询问是否下车。
  • Reactor做法:汽车是乘客访问的主体(Reactor),乘客上车后,到售票员(acceptor)处登记,之后乘客便可以休息睡觉去了,当到达乘客所要到达的目的地时(指定的事件发生,乘客到了下车地点),售票员将其唤醒即可。

2.2 更加形象例子

这部分内容主要来自:https://blog.csdn.net/russell_tao/article/details/17452997
https://blog.csdn.net/u013074465/article/details/46276967

传统编程方法
就好像是到了银行营业厅里,每个窗口前排了长队,业务员们在窗口后一个个的解决客户们的请求。一个业务员可以尽情思考着客户A依次提出的问题,例如: “我要买2万XX理财产品。”
“看清楚了,5万起售。”
“等等,查下我活期余额。”
“余额5万。”
“那就买 5万吧。”
业务员开始录入信息。
“对了,XX理财产品年利率8%?”
“是预期8%,最低无利息保本。”
“早不说,拜拜,我去买余额宝。”
业务员无表情的删着已经录入的信息进行事务回滚。
“下一个!”

IO复用方法
用了IO复用则是大师业务员开始挑战极限,在超大营业厅里给客户们人手一个牌子,黑压压的客户们都在大厅中,有问题时举牌申请提问,大师目光敏锐点名指定某人提问,该客户迅速得到大师的答复后,要经过一段时间思考,查查自己的银袋子,咨询下LD,才能再次进行下一个提问,直到得到完整的满意答复退出大厅。例如:大师刚指导A填写转帐单的某一项,B又来申请兑换泰铢,给了B兑换单后,C又来办理定转活,然后D与F在争抢有限的圆珠笔时出现了不和谐现象,被大师叫停业务,暂时等待。

这就是基于事件驱动的IO复用编程比起传统1线程1请求的方式来,有难度的设计点了,客户们都是上帝,既不能出错,还不能厚此薄彼。

当没有Reactor时,我们可能的设计方法是这样的:大师把每个客户的提问都记录下来,当客户A提问时,首先查阅A之前问过什么做过什么,这叫联系上下文,然后再根据上下文和当前提问查阅有关的银行规章制度,有针对性的回答A,并把回答也记录下来。当圆满回答了A的所有问题后,删除A的所有记录。

2.3 在程序中

某一瞬间,服务器共有10万个并发连接,此时,一次IO复用接口的调用返回了100个活跃的连接等待处理。先根据这100个连接找出其对应的对象,这并不难,epoll的返回连接数据结构里就有这样的指针可以用。接着,循环的处理每一个连接,找出这个对象此刻的上下文状态,再使用read、write这样的网络IO获取此次的操作内容,结合上下文状态查询此时应当选择哪个业务方法处理,调用相应方法完成操作后,若请求结束,则删除对象及其上下文。

这样,我们就陷入了面向过程编程方法之中了,在面向应用、快速响应为王的移动互联网时代,这样做早晚得把自己玩死。我们的主程序需要关注各种不同类型的请求,在不同状态下,对于不同的请求命令选择不同的业务处理方法。这会导致随着请求类型的增加,请求状态的增加,请求命令的增加,主程序复杂度快速膨胀,导致维护越来越困难,苦逼的程序员再也不敢轻易接新需求、重构。

Reactor是解决上述软件工程问题的一种途径,它也许并不优雅,开发效率上也不是最高的,但其执行效率与面向过程的使用IO复用却几乎是等价的,所以,无论是nginx、memcached、redis等等这些高性能组件的代名词,都义无反顾的一头扎进了反应堆的怀抱中。

Reactor模式可以在软件工程层面,将事件驱动框架分离出具体业务,将不同类型请求之间用OO的思想分离。通常,Reactor不仅使用IO复用处理网络事件驱动,还会实现定时器来处理时间事件的驱动(请求的超时处理或者定时任务的处理),就像下面的示意图:

这幅图有5点意思:

  • 处理应用时基于OO思想,不同的类型的请求处理间是分离的。例如,A类型请求是用户注册请求,B类型请求是查询用户头像,那么当我们把用户头像新增多种分辨率图片时,更改B类型请求的代码处理逻辑时,完全不涉及A类型请求代码的修改。
  • 应用处理请求的逻辑,与事件分发框架完全分离。什么意思呢?即写应用处理时,不用去管何时调用IO复用,不用去管什么调用epoll_wait,去处理它返回的多个socket连接。应用代码中,只关心如何读取、发送socket上的数据,如何处理业务逻辑。事件分发框架有一个抽象的事件接口,所有的应用必须实现抽象的事件接口,通过这种抽象才把应用与框架进行分离。
  • Reactor上提供注册、移除事件方法,供应用代码使用,而分发事件方法,通常是循环的调用而已,是否提供给应用代码调用,还是由框架简单粗暴的直接循环使用,这是框架的自由。
  • IO多路复用也是一个抽象,它可以是具体的select,也可以是epoll,它们只必须提供采集到某一瞬间所有待监控连接中活跃的连接。
  • 定时器也是由Reactor对象使用,它必须至少提供4个方法,包括添加、删除定时器事件,这该由应用代码调用。最近超时时间是需要的,这会被反应堆对象使用,用于确认select或者epoll_wait执行时的阻塞超时时间,防止IO的等待影响了定时事件的处理。遍历也是由反应堆框架使用,用于处理定时事件。

2.4 Reactor的几种模式

参考资料:http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf
在web服务中,很多都涉及基本的操作:read request、decode request、process service、encod reply、send reply等。

1 单线程模式

这是最简单的Reactor单线程模型。Reactor线程是个多面手,负责多路分离套接字,Accept新连接,并分派请求到处理器链中。该模型适用于处理器链中业务处理组件能快速完成的场景。不过这种单线程模型不能充分利用多核资源,所以实际使用的不多。

2 多线程模式(单Reactor)

该模型在事件处理器(Handler)链部分采用了多线程(线程池),也是后端程序常用的模型。

3 多线程模式(多个Reactor)

比起第二种模型,它是将Reactor分成两部分,mainReactor负责监听并accept新连接,然后将建立的socket通过多路复用器(Acceptor)分派给subReactor。subReactor负责多路分离已连接的socket,读写网络数据;业务处理功能,其交给worker线程池完成。通常,subReactor个数上可与CPU个数等同。

三、Proacotr模型

Proactor是和异步I/O相关的。
在Reactor模式中,事件分离者等待某个事件或者可应用多个操作的状态发生(比如文件描述符可读写,或者是socket可读写),事件分离器就把这个事件传给事先注册的处理器(事件处理函数或者回调函数),由后者来做实际的读写操作。

在Proactor模式中,事件处理者(或者代由事件分离者发起)直接发起一个异步读写操作(相当于请求),而实际的工作是由操作系统来完成的。发起时,需要提供的参数包括用于存放读到数据的缓存区,读的数据大小,或者用于存放外发数据的缓存区,以及这个请求完后的回调函数等信息。事件分离者得知了这个请求,它默默等待这个请求的完成,然后转发完成事件给相应的事件处理者或者回调。

可以看出两者的区别:Reactor是在事件发生时就通知事先注册的事件(读写由处理函数完成);Proactor是在事件发生时进行异步I/O(读写由OS完成),待IO完成事件分离器才调度处理器来处理。

举个例子,将有助于理解Reactor与Proactor二者的差异,以读操作为例(类操作类似)。
在Reactor(同步)中实现读:

  • 注册读就绪事件和相应的事件处理器
  • 事件分离器等待事件
  • 事件到来,激活分离器,分离器调用事件对应的处理器。
  • 事件处理器完成实际的读操作,处理读到的数据,注册新的事件,然后返还控制权。

Proactor(异步)中的读:

  • 处理器发起异步读操作(注意:操作系统必须支持异步IO)。在这种情况下,处理器无视IO就绪事件,它关注的是完成事件。
  • 事件分离器等待操作完成事件
  • 在分离器等待过程中,操作系统利用并行的内核线程执行实际的读操作,并将结果数据存入用户自定义缓冲区,最后通知事件分离器读操作完成。
  • 事件分离器呼唤处理器。
  • 事件处理器处理用户自定义缓冲区中的数据,然后启动一个新的异步操作,并将控制权返回事件分离器。

四、常见的I/O编程框架

对比几个常见的I/O编程框架:libevent,libev,libuv,aio,boost.Asio。

4.1 libevent

libevent是一个C语言写的网络库,官方主要支持的是类linux操作系统,最新的版本添加了对windows的IOCP的支持。在跨平台方面主要通过select模型来进行支持。 设计模式 :libevent为Reactor模式; 层次架构:livevent在不同的操作系统下,做了多路复用模型的抽象,可以选择使用不同的模型,通过事件函数提供服务; 可移植性 :libevent主要支持linux平台,freebsd平台,其他平台下通过select模型进行支持,效率不是太高; 事件分派处理 :libevent基于注册的事件回调函数来实现事件分发; 涉及范围 :libevent只提供了简单的网络API的封装,线程池,内存池,递归锁等均需要自己实现; 线程调度 :libevent的线程调度需要自己来注册不同的事件句柄; 发布方式 :libevent为开源免费的,一般编译为静态库进行使用; 开发难度 :基于libevent开发应用,相对容易,具体可以参考memcached这个开源的应用,里面使用了 libevent这个库。

4.2 libev

libevent/libev是两个名字相当相近的I/O Library。既然是库,第一反应就是对api的包装。epoll在linux上已经存在了很久,但是linux是SysV的后代,BSD及其衍生的MAC就没有,只有kqueue。

libev v.s libevent。既然已经有了libevent,为什么还要发明一个轮子叫做libev?

http://www.cnblogs.com/Lifehacker/p/whats_the_difference_between_libevent_and_libev_chinese.html
http://stackoverflow.com/questions/9433864/whats-the-difference-between-libev-and-libevent

上面是libev的作者对于这个问题的回答,下面是网摘的中文翻译:

就设计哲学来说,libev的诞生,是为了修复libevent设计上的一些错误决策。例如,全局变量的使用,让libevent很难在多线程环境中使用。watcher结构体很大,因为它们包含了I/O,定时器和信号处理器。额外的组件如HTTP和DNS服务器,因为拙劣的实现品质和安全问题而备受折磨。定时器不精确,而且无法很好地处理时间跳变。

总而言之,libev试图做好一件事而已(目标是成为POSIX的事件库),这是最高效的方法。libevent则尝试给你全套解决方案(事件库,非阻塞IO库,http库,DNS客户端)libev 完全是单线程的,没有DNS解析。

libev解决了epoll, kqueuq等API不同的问题。保证使用livev的程序可以在大多数 *nix 平台上运行(对windows不太友好)。但是 libev 的缺点也是显而易见,由于基本只是封装了 Event Library,用起来有诸多不便。比如 accept(3) 连接以后需要手动 setnonblocking 。从 socket 读写时需要检测 EAGAIN 、EWOULDBLOCK 和 EINTER 。这也是大多数人认为异步程序难写的根本原因。

4.3 libuv

libuv是Joyent给Node做的I/O Library。libuv 需要多线程库支持,其在内部维护了一个线程池来 处理诸如getaddrinfo(3) 这样的无法异步的调用。同时,对windows用户友好,Windows下用IOCP实现,官网http://docs.libuv.org/en/v1.x/

4.4 boost.Asio

Boost.Asio类库,其就是以Proactor这种设计模式来实现。
参见:Proactor(The Boost.Asio library is based on the Proactor pattern. This design note outlines the advantages and disadvantages of this approach.),
其设计文档链接:http://asio.sourceforge.net/boost_asio_0_3_7/libs/asio/doc/design/index.html http://stackoverflow.com/questions/11423426/how-does-libuv-compare-to-boost-asio

4.5 linux aio

linux有两种aio(异步机制),一是glibc提供的(bug很多,几乎不可用),一是内核提供的(BSD/mac也提供)。当然,机制不等于编程框架。

最后,本文介绍的同步Reactor模型比较多,后面的章节会以boost.Asio库为基础讲解为什么需要异步编程。

]]>
epoll介绍及使用 2019-11-24T16:32:00+00:00 goyas http://goyas.github.io/epoll-pipe 小程序功能:简单的父子进程之间的通讯,子进程负责每隔1s不断发送”message”给父进程,不需要跑多个应用实例,不需要用户输入。

首先上代码

#include<assert.h>
#include<signal.h>
#include<stdio.h>
#include<sys/epoll.h>
#include<sys/time.h>
#include<sys/wait.h>
#include<unistd.h>

int  fd[2];
int* write_fd;
int* read_fd;
const char msg[] = {'m','e','s','s','a','g','e'};

void SigHandler(int){
    size_t bytes = write(*write_fd, msg, sizeof(msg));
    printf("children process msg have writed : %ld bytes\n", bytes);
}

void ChildrenProcess() {
    struct sigaction sa;
    sa.sa_flags     =   0;
    sa.sa_handler   =   SigHandler;
    sigaction(SIGALRM, &sa, NULL);

    struct itimerval tick   =   {0};
    tick.it_value.tv_sec    =   1;   // 1s后将启动定时器
    tick.it_interval.tv_sec =   1;   // 定时器启动后,每隔1s将执行相应的函数

    // setitimer将触发SIGALRM信号,此处使用的是ITIMER_REAL,所以对应的是SIGALRM信号
    assert(setitimer(ITIMER_REAL, &tick, NULL) == 0);
    while(true) {
        pause();
    }
}

void FatherProcess() {
    epoll_event ev;
    epoll_event events[1];
    char buf[1024]  =   {0};
    int epoll_fd    =   epoll_create(1);
    ev.data.fd      =   *read_fd;
    ev.events       =   EPOLLIN | EPOLLET;
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, *read_fd, &ev);

    while (true) {
        int fds = epoll_wait(epoll_fd, events, sizeof(events), -1);
        if(events[0].data.fd == *read_fd) {
            size_t bytes = read(*read_fd, buf, sizeof(buf));
            printf("father process read %ld bytes = %s\n", bytes, buf);
        }
    }

    int status;
    wait(&status);
}

int main() {
    int ret = pipe(fd);
    if (ret != 0) {
        printf("pipe failed\n");
        return -1;
    }
    write_fd = &fd[1];
    read_fd  = &fd[0];

    pid_t pid = fork();
    if (pid == 0) {//child process
        ChildrenProcess();
    } else if (pid > 0) {//father process
        FatherProcess();
    }

    return 0;
}

函数功能分析

1、int setitimer(int which, const struct itimerval *value, struct itimerval *ovalue);
which:间歇计时器类型,有三种选择: ITIMER_REAL //数值为0,计时器的值实时递减,发送的信号是SIGALRM。
ITIMER_VIRTUAL //数值为1,进程执行时递减计时器的值,发送的信号是SIGVTALRM。
ITIMER_PROF //数值为2,进程和系统执行时都递减计时器的值,发送的信号是SIGPROF。

功能:在linux下如果定时如果要求不太精确的话,使用alarm()和signal()就行了(精确到秒),但是如果想要实现精度较高的定时功能的话,就要使用setitimer函数。setitimer()为Linux的API,并非C语言的Standard Library,setitimer()有两个功能,一是指定一段时间后,才执行某个function,二是每间格一段时间就执行某个function。it_interval指定间隔时间,it_value指定初始定时时间。如果只指定it_value,就是实现一次定时;如果同时指定 it_interval,则超时后,系统会重新初始化it_value为it_interval,实现重复定时;两者都清零,则会清除定时器。 当然,如果是以setitimer提供的定时器来休眠,只需阻塞等待定时器信号就可以了。

2、int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
signum:要操作的信号。
act:要设置的对信号的新处理方式。
oldact:原来对信号的处理方式。

3、int pipe(int filedes[2]);
返回值:成功,返回0,否则返回-1。参数数组包含pipe使用的两个文件的描述符。fd[0]:读管道,fd[1]:写管道。必须在fork()中调用pipe(),否则子进程不会继承文件描述符。两个进程不共享祖先进程,就不能使用pipe。但是可以使用命名管道。管道是一种把两个进程之间的标准输入和标准输出连接起来的机制,从而提供一种让多个进程间通信的方法,当进程创建管道时,每次都需要提供两个文件描述符来操作管道。其中一个对管道进行写操作,另一个对管道进行读操作。对管道的读写与一般的IO系统函数一致,使用write()函数写入数据,使用read()读出数据。

]]>
I/O多路复用模型 2019-11-24T15:23:00+00:00 goyas http://goyas.github.io/io-multiplexer 背景

在文章《unix网络编程》(12)五种I/O模型中提到了五种I/O模型,其中前四种:阻塞模型、非阻塞模型、信号驱动模型、I/O复用模型都是同步模型;还有一种是异步模型。

想写一个系列的文章,介绍从I/O多路复用到异步编程和RPC框架,整个演进过程,这一系列可能包括:

  1. I/O多路复用模型
  2. epoll介绍与使用
  3. Reactor和Proactor模型
  4. 为什么需要异步编程
  5. enable_shared_from_this用法分析
  6. 网络通信库和RPC

为什么有多路复用?

多路复用技术要解决的是“通信”问题,解决核心在于“同步事件分离器”(de-multiplexer),linux系统带有的分离器select、poll、epoll网上介绍的比较多,大家可以看看这篇介绍的不错的文章:我读过的最好的epoll讲解。通信的一方想要知道另一方的状态(以决定自己做什么),有两种方法: 一是轮询,二是消息通知。

轮询

轮询的一种典型的实现可能是这样的:当然这里的epoll_wait()也可以使用poll()或者select()替换。

while (true) {
    active_stream[] = epoll_wait(epollfd)
    for i in active_stream[] {
        read or write till
    }
}

轮询方式主要存在以下不足:

  • 增加系统开销。无论是任务轮询还是定时器轮询都需要消耗对应的系统资源。
  • 无法及时感知设备状态变化。在轮询间隔内的设备状态变化只有在下次轮询时才能被发现,这将无法满足对实时性敏感的应用场合。
  • 浪费CPU资源。无论设备是否发生状态改变,轮询总在进行。在实际情况中,大多数设备的状态改变通常不会那么频繁,轮询空转将白白浪费CPU时间片。

消息通知

其实现方式通常是: “阻塞-通知”机制。阻塞会导致一个任务(task_struct,进程或者线程)只能处理一个”I/O流”或者类似的操作,要处理多个,就要多个任务(需要多个进程或线程),因此灵活性上又不如轮询(一个任务足够),很矛盾。

select、poll、epoll对比
矛盾的根源就是”一”和”多”的矛盾: 希望一个任务处理多个对象,同时避免处理阻塞-通知机制的内部细节。解决方案是多路复用(muliplex)。多路复用有3种基本方案,select()/poll()/epoll(),都是来解决这一矛盾的。

  • 通知代理: 用户把需要关心的对象注册给select()/poll()/epoll()函数。
  • 一对多: 所有的被关心的对象,只要有一个对象有了通知事件,select()/poll()/epoll()就会结束阻塞状态。
  • 方便性: 用户(程序员)不用再关心如何阻塞和被通知,以及哪些情况下会有通知产生。这件事情已经由上述几个系统调用做了,用户只需要实现”通知来了我该做什么”。

那么上面3个系统调用的区别是什么呢?
第一个select(),结合了轮询和阻塞两种方式,没有问题,每次有一个对象事件发生的时候,select()只是知道有事件发生了,具体是哪个对象发生的,不知道,需要从头到尾轮询一遍,复杂度是O(n)。poll函数相对select函数变化不大,只是提升了最大的可轮询的对象个数。epoll函数把时间复杂度降到O(1)。

为什么select慢而epoll效率高?
select()之所以慢,有几个原因: select()的参数是一个FD数组,意味着每次select调用,都是一次新的注册-阻塞-回调,每次select都要把一个数组从用户空间拷贝到内核空间,内核检测到某个对象状态变化并写入后,再从内核空间拷贝回用户空间,select再把这个数组读取一遍,并返回。这个过程非常低效。

epoll的解决方案相当于是一种对select()的算法优化: 它把select()一个函数做的事情分解成了3步,首先epoll_create()创建一个epollfd对象(相当于一个池子),然后所有被监听的fd通过epoll_ctrl()注册到这个池子,也就是为每个fd指定了一个内部的回调函数(这样,就没有了每次调用时的来回拷贝,用户空间的数组到内核空间只有这一次拷贝)。epoll_wait阻塞等待。在内核态有一个和epoll_wait对应的函数调用,把就绪的fd,填入到一个就绪列表中,而epoll_wait读取这个就绪列表,做到了快速返回(O(1))。

详细的对比可以参考select、poll、epoll之间的区别总结

有了上面的原理介绍,这里举例来说明下epoll到底是怎么使用的,加深理解。举两个例子:

  • 一个是比较简单的父子进程通信的例子,单个小程序,不需要跑多个应用实例,不需要用户输入。https://goyas.github.io/epoll-pipe/
  • 一个是比较实战的socket+epoll,毕竟现实案例中哪有两个父子进程间通讯这么简单的应用场景。

有了多路复用,难道还不够?

有了I/O复用,有了epoll已经可以使服务器并发几十万连接的同时,维持高TPS了,难道这还不够吗?答案是,技术层面足够了,但在软件工程层面却是不够的。例如,总要有个for循环去调用epoll,总来处理epoll的返回,这是每次都要重复的工作。for循环体里面写什么—-通知返回之后,做事情的程序最好能以一种回调的机制,提供一个编程框架,让程序更有结构一些。另一方面,如果希望每个事件通知之后,做的事情能有机会被代理到某个线程里面去单独运行,而线程完成的状态又能通知回主任务,那么”异步”的进制就必须被引入。

所以,还有两个问题要解决,一是”编程框架”,一是”异步”。我们先看几个目前流行的框架,大部分框架已经包含了某种异步的机制。我们接下来的篇章将介绍“编程框架”和“异步I/O模型”。

]]>
深度学习分布式模型 2019-11-03T19:56:00+00:00 goyas http://goyas.github.io/deep-learning 背景

随着各大企业和研究机构在PyTorch、TensorFlow、Keras、MXNet等深度学习框架上面训练模型越来越多,项目的数据和计算能力需求急剧增加。在大部分的情况下,模型是可以在单个或多个GPU平台的服务器上运行的,但随着数据集的增加和训练时间的增长,有些训练需要耗费数天甚至数周的时间,我们拿COCO和Google最近Release出来的Open Image dataset v4来做比较,训练一个resnet152的检测模型,在COCO上大概需要40个小时,而在OIDV4上大概需要40天,这还是在各种超参数正确的情况下,如果加上调试的时间,可能一个模型调完就该过年了吧。单张CPU卡、或者单台服务器上的多张GPU卡,已经远远不能够满足内部训练任务的需求。因此,分布式训练的效率,即使用多台服务器协同进行训练,现在成为了深度学习系统的核心竞争力。

一、分布式训练系统架构

分布式训练系统架构主要有两种:

  • Parameter Server Architecture(就是常见的PS架构,参数服务器)
  • Ring-allreduce Architecture

1.1 Parameter Server架构

在Parameter Server架构(PS架构)中,集群中的节点被分为两类:parameter server和worker。其中parameter server存放模型的参数,而worker负责计算参数的梯度。在每个迭代过程,worker从parameter sever中获得参数,然后将计算的梯度返回给parameter server,parameter server聚合从worker传回的梯度,然后更新参数,并将新的参数广播给worker。见下图的左边部分。

1.2 Ring-allreduce架构

在Ring-allreduce架构中,各个设备都是worker,并且形成一个环,如上图所示,没有中心节点来聚合所有worker计算的梯度。在一个迭代过程,每个worker完成自己的mini-batch训练,计算出梯度,并将梯度传递给环中的下一个worker,同时它也接收从上一个worker的梯度。对于一个包含N个worker的环,各个worker需要收到其它N-1个worker的梯度后就可以更新模型参数。其实这个过程需要两个部分:scatter-reduce和allgather,百度开发了自己的allreduce框架,并将其用在了深度学习的分布式训练中。

相比PS架构,Ring-allreduce架构有如下优点:

  • 带宽优化,因为集群中每个节点的带宽都被充分利用。而PS架构,所有的worker计算节点都需要聚合给parameter server,这会造成一种通信瓶颈。parameter server的带宽瓶颈会影响整个系统性能,随着worker数量的增加,其加速比会迅速的恶化。
  • 此外,在深度学习训练过程中,计算梯度采用BP算法,其特点是后面层的梯度先被计算,而前面层的梯度慢于前面层,Ring-allreduce架构可以充分利用这个特点,在前面层梯度计算的同时进行后面层梯度的传递,从而进一步减少训练时间。在百度的实验中,他们发现训练速度基本上线性正比于GPUs数目(worker数)。

二、通用机器学习框架对分布式模型的支持

2.1 Tensorflow原生PS架构

通过TensorFlow原生的PS-Worker架构可以采用分布式训练进而提升我们的训练效果,但是实际应用起来并不轻松:

  • 概念多,学习曲线陡峭:tensorflow的集群采用的是parameter server架构,因此引入了比较多复杂概念
  • 修改的代码量大:如果想把单机单卡的模型,移植到多机多卡,涉及的代码量是以天记的,慢的话甚至需要一周。
  • 需要多台机子跑不同的脚本:tensorflow集群是采用parameter server架构的,要想跑多机多卡的集群,每个机子都要启动一个client,即跑一个脚本,来启动训练,100个机子,人就要崩溃了。
  • ps和worker的比例不好选取:tensorflow集群要将服务器分为ps和worker两种job类型,ps设置多少性能最近并没有确定的计算公式。
  • 性能损失较大:tensorflow的集群性能并不好,当超过一定规模时,性能甚至会掉到理想性能的一半以下。

2.2 Pytorch分布式简介

PyTorch用1.0稳定版本开始,torch.distributed软件包和torch.nn.parallel.DistributedDataParallel模块由全新的、重新设计的分布式库提供支持。 新的库的主要亮点有:

  • 新的 torch.distributed 是性能驱动的,并且对所有后端 (Gloo,NCCL 和 MPI) 完全异步操作
  • 显着的分布式数据并行性能改进,尤其适用于网络较慢的主机,如基于以太网的主机
  • 为torch.distributed package中的所有分布式集合操作添加异步支持
  • 在Gloo后端添加以下CPU操作:send,recv,reduce,all_gather,gather,scatter
  • 在NCCL后端添加barrier操作
  • 为NCCL后端添加new_group支持

1.0的多机多卡的计算模型并没有采用主流的Parameter Server结构,而是直接用了Uber Horovod的形式,也是百度开源的RingAllReduce算法。

2.3 分布式Horovod介绍

Horovod 是一套支持TensorFlow, Keras, PyTorch, and Apache MXNet 的分布式训练框架,由 Uber 构建并开源,Horovod 的主要主要有两个优点:

  • 采用Ring-Allreduce算法,提高分布式设备的效率;
  • 代码改动少,能够简化分布式深度学习项目的启动与运行。

Horovod 是一个兼容主流计算框架的分布式机器学习训练框架,主要基于的算法是 AllReduce。 使用 horovod 有一定的侵入性,代码需要一定的修改才能变成适配分布式训练,但是有一个好处就是适配的成本不高,并且 horovod 提供的各种框架的支持可以让 horovod 比较好的在各个框架的基础上使用,他支持 tensorflow/keras/mxnet/pytorch,MPI 的实现也有很多,比如 OpenMPI 还有 Nvidia 的 NCCL,还有 facebook 的 gloo,他们都实现了一种并行计算的通信和计算方式。而且 horovod 的本身的实现也很简单。

参考文献:
https://eng.uber.com/horovod/
https://www.aiuai.cn/aifarm740.html
https://zhuanlan.zhihu.com/p/40578792
https://ggaaooppeenngg.github.io/zh-CN/2019/08/30/horovod-实现分析/
https://blog.csdn.net/zwqjoy/article/details/89552432
https://www.jiqizhixin.com/articles/2019-04-11-21
https://zhuanlan.zhihu.com/p/50116885
https://zhuanlan.zhihu.com/p/70603273
https://juejin.im/post/5cbc6dbd5188253236619ccb
https://zhpmatrix.github.io/2019/07/18/speed-up-pytorch/
https://cloud.tencent.com/developer/article/1117910
https://www.infoq.cn/article/J-EckTKHH9lNYdc6QacH

]]>
一个极简的分布式文件系统 2019-09-30T09:14:00+00:00 goyas http://goyas.github.io/goyas-fs 前言

开源的分布式存储系统比较多,比较有名的有:Ceph、GlusterFS、HDFS、TFS等。这些系统都比较复杂,代码动则几十上百万行,这些系统对初学者来说门槛比较高,特别是对于从事非分布式存储行业,但又想跨行学习分布式的同学来说,往往有这想法,但是不知道怎么入手。本文介绍之前实现的一个C++极简版的分布式文件系统 https://github.com/goyas/goya-fs, 代码只有一两百行,当然功能也很粗糙,只实现了简单的mkdir和ls这两条命令,但就像刚刚描述的,目的是学习,也便于大家对分布式有体感之后,方便阅读其他庞大的分布式存储系统,当然以后有空时间也会不断完善功能。

对于嵌入式,或者主要是从事单机开发的程序员来说,没接触分布式之前,都会感觉很神秘,往往会被高并发、海量数据分析处理等名词唬住。其实,职位没有智商之分,区别也就在于你有没有亲自动手摸过这些玩意儿。以往的经验告诉我,就算不会的东西,一个版本的时间,只要你稍微努点力基本就会达到行业的基本水平,当然越往上走就要看自己的兴趣和时间投入了。

好了,言归正传,下面开始介绍这个简单的分布式文件系统,选用的基础组件是leveldb + goyas-rpc,leveldb作为存储底座,goyas-rpc作为进程之间通信使用。有关leveldb的介绍网上非常多,这里就不再骜述,goyas-rpc可以参考之前的 一个基于protobuf的极简RPC 这篇文章。

思考

如果让你设计分布式文件系统,你会怎么设计?
1、如果自己设计一个简单的分布式存储系统,对于文件的读取存盘,你会怎么设计?
2、比如执行下面的命令经过怎么样的IO路径local_file文件才会存储到磁盘? ./fs_client put local_file /user/ —把local_file文件存放到文件系统/user目录
3、怎么让local_file文件存储到分布式文件系统的3个不同结点,并且3副本保存?

架构设计

系统架构设计采用经典的GFS分布式存储模型,由3个不同的角色(client、master、chunkserver)负责管理不同的事务,client作为客户端,接受来自用户的请求。master作为元数据及namespace存储管理。chunkserver和磁盘打交道,作为最终的单机存储引擎。 执行./fs_client put local_file /user/ 命令,会大致经历下面图里面从左到右的流程,最终调用系统调用write把local_file存放到存储介质。

fs_client

fs_client用于接受用户的请求,比如:./fs_client mkdir /file1执行这条命令,会最初调用下面的函数接口

int FileSystemImpl::CreateDirectory(char* path) {
  printf("Create directory %s\n", path);
  CreateFileRequest  request;
  CreateFileResponse response;
  request.set_sequence_id(0);
  request.set_file_name(path);
  request.set_type((1<<9)|0755);
  bool ret = rpc_wrapper_->SendRequest(masterserver_stub_, 
    &MasterServer_Stub::CreateFile, &request, &response, 5, 3);
  if (!ret || response.status() != 0) {
    printf("Create directory fail\n");
    return -1;
  }
  
  return 0;
}

函数功能:把序列号、文件名及文件类型通过RPC发送到元数据管理进程masterserver进行处理。其实这里比我们经常单机环境写的fopen、fwrite也就仅仅多了个RPC,需要通过它把不用节点的信息发送到其他节点,呵呵,这就是分布式!再来看看masterserver进程收到这个request消息后干了些啥?

masterserver

void MasterServerImpl::CreateFile(google::protobuf::RpcController* controller,
  const ::goya::fs::CreateFileRequest* request,
  goya::fs::CreateFileResponse* response,
  google::protobuf::Closure* done) {
  printf("masterserver create file\n");
  response->set_sequence_id(request->sequence_id());
  const std::string& filename = request->file_name();
  if (filename.empty() || filename[0] != '/') {
    printf("path format error\n");
    response->set_status(3);
    done->Run();
    return ; 
  }

  std::string file_value;
  leveldb::Status s;
  s = db_->Get(leveldb::ReadOptions(), filename, &file_value);
  if (s.IsNotFound()) {
    FileInfoProto file_info;
    file_info.set_time(time(NULL));
    file_info.set_type(request->type());
    file_info.SerializeToString(&file_value);
    s = db_->Put(leveldb::WriteOptions(), filename, file_value);
    if (s.ok()) {
      printf("CreateFile %s file\n", filename.c_str());
      response->set_status(0);
    } else {
      printf("CreateFile %s file\n", filename.c_str());
      response->set_status(2);
    }
  } else {
    printf("CreateFile %s fail: already exist\n", filename.c_str());
    response->set_status(1);
  }
  done->Run();
}

函数功能:从收到的消息中提取出文件名作为key,文件类型和时间作为value,然后使用KV存储引擎leveldb存储,最后把完成消息通过response发送给调用方。这个过程到这里就完了,是不是很简单?!

ls命令的功能和上面的介绍相反,就是把上面mkdir创建的文件信息给列出来,这里就不讲究了,大家可以去看看源码,也是非常简单。

chunkserver

待实现

写在最后

到这里整个分布式文件系统就讲解完了,当然真正的分布式存储系统远比这个复杂的太多,不然怎么会到百万级别的代码。希望这个简单的文件系统的讲解对你有点帮忙。

马上就是祖国70周年生日了,提前给庆生了^_^

]]>
一个基于protobuf的极简RPC 2019-09-22T09:48:00+00:00 goyas http://goyas.github.io/grpc 前言

RPC采用客户机/服务器模式实现两个进程之间的相互通信,socket是RPC经常采用的通信手段之一。当然,除了socket,RPC还有其他的通信方法:http、管道。。。网络开源的RPC框架也比较多,一个功能比较完善的RPC框架代码比较多,如何快速的从这些代码盲海中梳理清楚主要脉络,对于初学者来说比较困难,本文介绍之前自己实现的一个C++极简版的RPC框架(https://github.com/goyas/goya-rpc),代码只有100多行,希望尽量用少的代码来描述框架以减轻初学者的学习负担,同时便于大家阅读网络上复杂的RPC源码。

1、经典的RPC框架echo例子里面,EchoServer_Stub类是哪里来的?
2、为什么stub.Echo(&controller, &request, &response, nullptr); 调用就执行到server端的Echo函数?
3、stub.Echo(&controller, &request, &response, nullptr); 最后一个参数是nullptr,调用到server端的Echo(controller, request, response, done) 函数时,done指针为什么不为空了?

让我们通过下面这个简单的RPC框架,一层一层解开上面的疑惑。

echo_server.cc

class EchoServerImpl : public goya::rpc::echo::EchoServer {
public:
  EchoServerImpl() {}
  virtual ~EchoServerImpl() {}

private:
  virtual void Echo(google::protobuf::RpcController* controller,
                    const goya::rpc::echo::EchoRequest* request,
                    goya::rpc::echo::EchoResponse* response,
                    google::protobuf::Closure* done) 
  {
    std::cout << "server received client msg: " << request->message() << std::endl;
    response->set_message(
      "server say: received msg: ***" + request->message() + std::string("***"));
    done->Run();
  }
};

int main(int argc, char* argv[]) 
{
  RpcServer rpc_server;

  goya::rpc::echo::EchoServer* echo_service = new EchoServerImpl();
  if (!rpc_server.RegisterService(echo_service, false)) {
    std::cout << "register service failed" << std::endl;
    return -1;
  }

  std::string server_addr("0.0.0.0:12321");
  if (!rpc_server.Start(server_addr)) {
    std::cout << "start server failed" << std::endl;
    return -1;
  }

  return 0;
}

echo_client.cc

int main(int argc, char* argv[]) 
{ 
  echo::EchoRequest   request;
  echo::EchoResponse  response;
  request.set_message("hello tonull, from client");

  char* ip          = argv[1];
  char* port        = argv[2];
  std::string addr  = std::string(ip) + ":" + std::string(port);
  RpcChannel    rpc_channel(addr);
  echo::EchoServer_Stub stub(&rpc_channel);

  RpcController controller;
  stub.Echo(&controller, &request, &response, nullptr);
  
  if (controller.Failed()) 
    std::cout << "request failed: %s" << controller.ErrorText().c_str();
  else
    std::cout << "resp: " << response.message() << std::endl;

  return 0;
}

上面是一个简单的Echo实例的代码,主要功能是:server端收到client发送来的消息,然后echo返回给client,功能非常简单,但是走完了整个流程。其他特性无非基于此的一些衍生。好了,我们现在来解析下这个源码,首先来看server端。

RpcServer rpc_server;
goya::rpc::echo::EchoServer* echo_service = new EchoServerImpl();
rpc_server.RegisterService(echo_service, false)
rpc_server.Start(server_addr)

最主要就上面四行代码,定义了两个对象rpc_server和echo_service,然后注册对象,启动服务。EchoServerImpl继承于EchoServer,讲到这里也许有人会问,我没有定义EchoServer这个类啊,它是从哪里来的?ok,那我们这里先跳到讲解下protobuf,讲完之后再回过头来继续。

protobuf

通过socket,client和server可以互相交互消息,但这种通信效率不高,一般选择在发送的时候把消息经过序列化,而在接受的时候采用反序列化解析就可以了,本文采用谷歌开源的protobuf作为消息序列化的方法,其他序列化的方法还有json和rlp。。。

首先按照proto格式,定义消息传输的内容, EchoRequest为请求消息,EchoRequest为响应消息,在EchoServer里面定义了Echo方法。

syntax = "proto3";
package goya.rpc.echo;
option cc_generic_services = true;

message EchoRequest {
  string message = 1;
}
message EchoResponse {
  string message = 1;
}
service EchoServer {
  rpc Echo(EchoRequest) returns(EchoResponse);
}

把定义的proto文件用protoc工具生成对应的echo_service.pb.h和 echo_service.pb.cc文件,网上有很多介绍怎么使用proto文件生成对应的pb.h和pb.c的文档,这里就不在过多描述。具体的也可以看工程里面的 sample/echo/CMakeLists.txt 文件。

service EchoService这一句会生成EchoService和EchoService_Stub两个类,分别是 server 端和 client 端需要关心的。

回到server

对 server 端,通过EchoService::Echo来处理请求,代码未实现,需要子类来 override。

void EchoService::Echo(::google::protobuf::RpcController* controller,
                         const ::echo::EchoRequest*,
                         ::echo::EchoResponse*,
                         ::google::protobuf::Closure* done) {
  // 代码未实现,需要server返回给client什么内容,就在这里填写
  controller->SetFailed("Method Echo() not implemented.");
  done->Run();
}

好了,我们现在回到上面没有讲完的server,server定义了EchoServerImpl对象,实现了Echo方法,功能也就是把client发送来的消息又返回给client。 server里面还没讲解完的是“注册”和“启动”服务两个功能,我们直接跳到代码讲解。

RegisterService注册的功能非常简单,就是把我们自己定义的EchoServerImpl对象echo_service给保存在services_这个数据结构里。

bool RpcServerImpl::RegisterService(google::protobuf::Service* service, bool ownership) {
  services_[0] = service;
  return true;
}

Start启动服务的功能也很简单,就是一个socket不断的accept远端传送过来的数据,然后进行处理。

bool RpcServerImpl::Start(std::string& server_addr) {
  ...
  while (true) {
    auto socket = boost::make_shared<boost::asio::ip::tcp::socket>(io);
    acceptor.accept(*socket);

    std::cout << "recv from client: " << socket->remote_endpoint().address() << std::endl;

    int request_data_len = 256;
    std::vector<char> contents(request_data_len, 0);
    socket->receive(boost::asio::buffer(contents));

    ProcRpcData(std::string(&contents[0], contents.size()), socket);
  }
}

回到client

RpcChannel    rpc_channel(addr);
echo::EchoServer_Stub stub(&rpc_channel);
RpcController controller;
stub.Echo(&controller, &request, &response, nullptr);

对于client 端,最主要就上面四条语句,定义了RpcChannel、EchoServer_Stub、RpcController三个不同的对象,通过EchoService_Stub来发送数据,EchoService_Stub::Echo调用了::google::protobuf::Channel::CallMethod方法,但是Channel是一个纯虚类,需要 RPC 框架在子类里实现需要的功能。

class EchoService_Stub : public EchoService {
  ...
  void Echo(::google::protobuf::RpcController* controller,
                       const ::echo::EchoRequest* request,
                       ::echo::EchoResponse* response,
                       ::google::protobuf::Closure* done);
 private:
    ::google::protobuf::RpcChannel* channel_;
};

void EchoService_Stub::Echo(::google::protobuf::RpcController* controller,
                              const ::echo::EchoRequest* request,
                              ::echo::EchoResponse* response,
                              ::google::protobuf::Closure* done) {
  channel_->CallMethod(descriptor()->method(0), controller, request, response, done);
}

也就是说,执行stub.Echo(&controller, &request, &response, nullptr); 这条语句实际是执行到了

void RpcChannelImpl::CallMethod(const ::google::protobuf::MethodDescriptor* method, 
  ::google::protobuf::RpcController* controller,
  const ::google::protobuf::Message* request,
  ::google::protobuf::Message* response,
  ::google::protobuf::Closure* done) {
  std::string request_data = request->SerializeAsString();
  socket_->send(boost::asio::buffer(request_data));

  int  resp_data_len = 256;
  std::vector<char> resp_data(resp_data_len, 0);
  socket_->receive(boost::asio::buffer(resp_data));

  response->ParseFromString(std::string(&resp_data[0], resp_data.size()));
}

RpcChannelImpl::CallMethod主要做了什么呢?主要两件事情:1、把request消息通过socket发送给远端;2、同时接受来自远端的reponse消息。

讲到这里基本流程就梳理的差不多了,文章开头的几个问题也基本在讲解的过程中回答了,对于后面两个问题,这里再划重点讲解下,stub.Echo(&controller, &request, &response, nullptr); 最后一个参数是nullptr,这里你填啥都没啥卵用,因为在RpcChannelImpl::CallMethod中根本就没使用到,而为什么又要加这个参数呢?这纯属是为了给人一种错觉:client端执行stub.Echo(&controller, &request, &response, nullptr);就是调用到了server端的EchoServerImpl::Echo(*controller, *request, *response, *done),使远程调用看起来像本地调用一样(至少参数类型及个数是一致的)。而其实这也是最令初学者疑惑的地方。

而本质上,server端的EchoServerImpl::Echo(*controller, *request, *response, *done)函数其实是在接受到数据后,从这里调用过来的,具体见下面代码:

void RpcServerImpl::ProcRpcData(const std::string& serialzied_data,
  const boost::shared_ptr<boost::asio::ip::tcp::socket>& socket) {
  auto service      = services_[0];
  auto m_descriptor = service->GetDescriptor()->method(0);
  auto recv_msg = service->GetRequestPrototype(m_descriptor).New();
  auto resp_msg = service->GetResponsePrototype(m_descriptor).New();
  recv_msg->ParseFromString(serialzied_data);
  
  // 构建NewCallback对象
  auto done = google::protobuf::NewCallback(
    this, &RpcServerImpl::OnCallbackDone, resp_msg, socket);
  RpcController controller;
  service->CallMethod(m_descriptor, &controller, recv_msg, resp_msg, done);
}

service->CallMethod(m_descriptor, &controller, recv_msg, resp_msg, done); 会调用到EchoServer::CallMethod,protobuf会根据method->index()找到对应的执行函数,EchoServerImpl实现了Echo函数,所以上面的service->CallMethod(m_descriptor, &controller, recv_msg, resp_msg, done); 会执行到EchoServerImpl::Echo,这进一步说明了 EchoServerImpl::Echo 跟stub.Echo()调用没有鸡毛关系,唯一有的关系,确实发起动作是stub.Echo(); 中间经过了无数次解析最后确实是调到了EchoServerImpl::Echo。

void EchoServer::CallMethod(const ::PROTOBUF_NAMESPACE_ID::MethodDescriptor* method,
                             ::PROTOBUF_NAMESPACE_ID::RpcController* controller,
                             const ::PROTOBUF_NAMESPACE_ID::Message* request,
                             ::PROTOBUF_NAMESPACE_ID::Message* response,
                             ::google::protobuf::Closure* done) {
  GOOGLE_DCHECK_EQ(method->service(), file_level_service_descriptors_echo_5fservice_2eproto[0]);
  switch(method->index()) {
    case 0:
      Echo(controller,
             ::PROTOBUF_NAMESPACE_ID::internal::DownCast<const ::goya::rpc::echo::EchoRequest*>(
                 request),
             ::PROTOBUF_NAMESPACE_ID::internal::DownCast<::goya::rpc::echo::EchoResponse*>(
                 response),
             done);
      break;
    default:
      GOOGLE_LOG(FATAL) << "Bad method index; this should never happen.";
      break;
  }
}

22 Sep 2019

]]>
你好,世界 2019-09-15T00:00:00+00:00 goyas http://goyas.github.io/hello-world 我的github.io第一篇文章

15 Sep 2019

]]>