This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,377 @@
<audio id="audio" title="15 | 序列化:简单通用的数据交换格式有哪些?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/32/8d/32f57c4e71ed343adef8d531f031f28d.mp3"></audio>
你好我是Chrono。
在前面的三个单元里我们学习了C++的语言特性和标准库算是把C++的编程范式、生命周期、核心特性、标准库的内容整体过了一遍。从今天起,我们的学习之旅又将开启一个新的篇章。
C++语言和标准库很强大,功能灵活,组件繁多,但也只能说是构建软件这座大厦的基石。想要仅凭它们去“包打天下”,不能说是绝对不可行,但至少是“吃力难讨好”。
还是那句老话“不要重复发明轮子。”Reinventing the wheel虽然很多C++程序员都热衷于此,但我觉得对于你我这样的“凡人”,还是要珍惜自己的时间和精力,把有限的资源投入到能有更多产出的事情上。
所以,接下来的这几节课,我会介绍一些第三方工具,精选出序列化/反序列化、网络通信、脚本语言混合编程和性能分析这四类工具,弥补标准库的不足,节约你的开发成本,让你的工作更有效率。
今天,我先来说一下序列化和反序列化。这两个功能在软件开发中经常遇到,你可能很熟悉了,所以我只简单解释一下。
序列化,就是把内存里“活的对象”转换成静止的字节序列,便于存储和网络传输;而反序列化则是反向操作,从静止的字节序列重新构建出内存里可用的对象。
我借用《三体》里的内容,打一个形象的比喻:序列化就是“三体人”的脱水,变成干纤维,在乱纪元方便存储运输;反序列化就是“三体人”的浸泡,在恒纪元由干纤维再恢复成活生生的人。(即使没读过《三体》,也是很好理解的吧?)
接下来我就和你介绍三种既简单又高效的数据交换格式JSON、MessagePack和ProtoBuffer看看在C++里怎么对数据做序列化和反序列化。
## JSON
JSON是一种轻量级的数据交换格式采用纯文本表示所以是“human readable”阅读和修改都很方便。
由于JSON起源于“最流行的脚本语言”JavaScript所以它也随之得到了广泛的应用在Web开发领域几乎已经成为了事实上的标准而且还渗透到了其他的领域。比如很多数据库就支持直接存储JSON数据还有很多应用服务使用JSON作为配置接口。
在[JSON的官方网站](https://www.json.org/json-zh.html)上你可以找到大量的C++实现不过用起来都差不多。因为JSON本身就是个KV结构很容易映射到类似map的关联数组操作方式。
如果不是特别在意性能的话,选个你自己喜欢的就好。否则,你就要做一下测试,看哪一个更适合你的应用场景。
不过我觉得JSON格式注重的是方便易用在性能上没有太大的优势所以**一般选择JSON来交换数据通常都不会太在意性能不然肯定会改换其他格式了还是自己用着顺手最重要**。
下面就来说说我的个人推荐:“[JSON for Modern C++](https://github.com/nlohmann/json)”这个库。
JSON for Modern C++可能不是最小最快的JSON解析工具但功能足够完善而且使用方便仅需要包含一个头文件“json.hpp”没有外部依赖也不需要额外的安装、编译、链接工作适合快速上手开发。
JSON for Modern C++可以用“git clone”下载源码或者更简单一点直接用wget获取头文件就行
```
git clone git@github.com:nlohmann/json.git # git clone
wget https://github.com/nlohmann/json/releases/download/v3.7.3/json.hpp # wget
```
JSON for Modern C++使用一个json类来表示JSON数据为了避免说的时候弄混我给这个类起了个别名json_t
```
using json_t = nlohmann::json;
```
json_t的序列化功能很简单和标准容器map一样用关联数组的“[]”来添加任意数据。
你不需要特别指定数据的类型,它会自动推导出恰当的类型。比如,连续多个“[]”就是嵌套对象array、vector或者花括号形式的初始化列表就是JSON数组map或者是花括号形式的pair就是JSON对象非常自然
```
json_t j; // JSON对象
j[&quot;age&quot;] = 23; // &quot;age&quot;:23
j[&quot;name&quot;] = &quot;spiderman&quot;; // &quot;name&quot;:&quot;spiderman&quot;
j[&quot;gear&quot;][&quot;suits&quot;] = &quot;2099&quot;; // &quot;gear&quot;:{&quot;suits&quot;:&quot;2099&quot;}
j[&quot;jobs&quot;] = {&quot;superhero&quot;}; // &quot;jobs&quot;:[&quot;superhero&quot;]
vector&lt;int&gt; v = {1,2,3}; // vector容器
j[&quot;numbers&quot;] = v; // &quot;numbers&quot;:[1,2,3]
map&lt;string, int&gt; m = // map容器
{{&quot;one&quot;,1}, {&quot;two&quot;, 2}}; // 初始化列表
j[&quot;kv&quot;] = m; // &quot;kv&quot;:{&quot;one&quot;:1,&quot;two&quot;:2}
```
添加完之后用成员函数dump()就可以序列化得到它的JSON文本形式。默认的格式是紧凑输出没有缩进如果想要更容易阅读的话可以加上指示缩进的参数
```
cout &lt;&lt; j.dump() &lt;&lt; endl; // 序列化,无缩进
cout &lt;&lt; j.dump(2) &lt;&lt; endl; // 序列化有缩进2个空格
```
json_t的反序列化功能同样也很简单只要调用静态成员函数parse()就行直接得到JSON对象而且可以用auto自动推导类型
```
string str = R&quot;({ // JSON文本原始字符串
&quot;name&quot;: &quot;peter&quot;,
&quot;age&quot; : 23,
&quot;married&quot; : true
})&quot;;
auto j = json_t::parse(str); // 从字符串反序列化
assert(j[&quot;age&quot;] == 23); // 验证序列化是否正确
assert(j[&quot;name&quot;] == &quot;peter&quot;);
```
json_t使用异常来处理解析时可能发生的错误如果你不能保证JSON数据的完整性就要使用try-catch来保护代码防止错误数据导致程序崩溃
```
auto txt = &quot;bad:data&quot;s; // 不是正确的JSON数据
try // try保护代码
{
auto j = json_t::parse(txt);// 从字符串反序列化
}
catch(std::exception&amp; e) // 捕获异常
{
cout &lt;&lt; e.what() &lt;&lt; endl;
}
```
对于通常的应用来说掌握了基本的序列化和反序列化就够用了不过JSON for Modern C++里还有很多高级用法比如SAX、BSON、自定义类型转换等。如果你需要这些功能可以去看[它的文档](https://github.com/nlohmann/json),里面写得都很详细。
## MessagePack
说完JSON再来说另外第二种格式MessagePack。
它也是一种轻量级的数据交换格式与JSON的不同之处在于它不是纯文本而是二进制。所以MessagePack就比JSON更小巧处理起来更快不过也就没有JSON那么直观、易读、好修改了。
由于二进制这个特点MessagePack也得到了广泛的应用著名的有Redis、Pinterest。
MessagePack支持几乎所有的编程语言你可以在[官网](https://msgpack.org/)上找到它的C++实现。
我常用的是官方库msgpack-c可以用apt-get直接安装。
```
apt-get install libmsgpack-dev
```
但这种安装方式有个问题可能发行方仓库里的是老版本像Ubuntu 16.04就是0.57),缺失很多功能,所以最好是从[GitHub](https://github.com/msgpack/msgpack-c)上下载最新版,编译时手动指定包含路径:
```
git clone git@github.com:msgpack/msgpack-c.git
g++ msgpack.cpp -std=c++14 -I../common/include -o a.out
```
和JSON for Modern C++一样msgpack-c也是仅头文件的库head only只要包含一个头文件“msgpack.hpp”就行了不需要额外的编译链接选项C版本需要用“-lmsgpackc”链接
但MessagePack的设计理念和JSON是完全不同的它没有定义JSON那样的数据结构而是比较底层只能对基本类型和标准容器序列化/反序列化,需要你自己去组织、整理要序列化的数据。
我拿vector容器来举个例子调用pack()函数序列化为MessagePack格式
```
vector&lt;int&gt; v = {1,2,3,4,5}; // vector容器
msgpack::sbuffer sbuf; // 输出缓冲区
msgpack::pack(sbuf, v); // 序列化
```
从代码里你可以看到它的用法不像JSON那么简单直观**必须同时传递序列化的输出目标和被序列化的对象**。
输出目标sbuffer是个简单的缓冲区你可以把它理解成是对字符串数组的封装`vector&lt;char&gt;`很像也可以用data()和size()方法获取内部的数据和长度。
```
cout &lt;&lt; sbuf.size() &lt;&lt; endl; // 查看序列化后数据的长度
```
除了sbuffer你还可以选择另外的zbuffer、fbuffer。它们是压缩输出和文件输出和sbuffer只是格式不同用法是相同的所以后面我就都用sbuffer来举例说明。
MessagePack反序列化的时候略微麻烦一些要用到函数unpack()和两个核心类object_handle和object。
函数unpack()反序列化数据得到的是一个object_handle再调用get()就是object
```
auto handle = msgpack::unpack( // 反序列化
sbuf.data(), sbuf.size()); // 输入二进制数据
auto obj = handle.get(); // 得到反序列化对象
```
这个object是MessagePack对数据的封装相当于JSON for Modern C++的JSON对象但你不能直接使用必须知道数据的原始类型才能转换还原
```
vector&lt;int&gt; v2; // vector容器
obj.convert(v2); // 转换反序列化的数据
assert(std::equal( // 算法比较两个容器
begin(v), end(v), begin(v2)));
```
因为MessagePack不能直接打包复杂数据所以用起来就比JSON麻烦一些你必须自己把数据逐个序列化连在一起才行。
好在MessagePack又提供了一个packer类可以实现串联的序列化操作简化代码
```
msgpack::sbuffer sbuf; // 输出缓冲区
msgpack::packer&lt;decltype(sbuf)&gt; packer(sbuf); // 专门的序列化对象
packer.pack(10).pack(&quot;monado&quot;s) // 连续序列化多个数据
.pack(vector&lt;int&gt;{1,2,3});
```
对于多个对象连续序列化后的数据反序列化的时候可以用一个偏移量offset参数来同样连续操作
```
for(decltype(sbuf.size()) offset = 0; // 初始偏移量是0
offset != sbuf.size();){ // 直至反序列化结束
auto handle = msgpack::unpack( // 反序列化
sbuf.data(), sbuf.size(), offset); // 输入二进制数据和偏移量
auto obj = handle.get(); // 得到反序列化对象
}
```
但这样还是比较麻烦能不能像JSON那样直接对类型序列化和反序列化呢
MessagePack为此提供了一个特别的宏MSGPACK_DEFINE把它放进你的类定义里就可以像标准类型一样被MessagePack处理。
下面定义了一个简单的Book类
```
class Book final // 自定义类
{
public:
int id;
string title;
set&lt;string&gt; tags;
public:
MSGPACK_DEFINE(id, title, tags); // 实现序列化功能的宏
};
```
它可以直接用于pack()和unpack()基本上和JSON差不多了
```
Book book1 = {1, &quot;1984&quot;, {&quot;a&quot;,&quot;b&quot;}}; // 自定义类
msgpack::sbuffer sbuf; // 输出缓冲区
msgpack::pack(sbuf, book1); // 序列化
auto obj = msgpack::unpack( // 反序列化
sbuf.data(), sbuf.size()).get(); // 得到反序列化对象
Book book2;
obj.convert(book2); // 转换反序列化的数据
assert(book2.id == book1.id);
assert(book2.tags.size() == 2);
cout &lt;&lt; book2.title &lt;&lt; endl;
```
使用MessagePack的时候你也要注意数据不完整的问题必须要用try-catch来保护代码捕获异常
```
auto txt = &quot;&quot;s; // 空数据
try // try保护代码
{
auto handle = msgpack::unpack( // 反序列化
txt.data(), txt.size());
}
catch(std::exception&amp; e) // 捕获异常
{
cout &lt;&lt; e.what() &lt;&lt; endl;
}
```
## ProtoBuffer
第三个要说的库就是著名的[ProtoBuffer](https://github.com/protocolbuffers/protobuf)通常简称为PB由Google出品。
PB也是一种二进制的数据格式但毕竟是工业级产品所以没有JSON和MessagePack那么“轻”相关的东西比较多要安装一个预处理器和开发库编译时还要链接动态库-lprotobuf
```
apt-get install protobuf-compiler
apt-get install libprotobuf-dev
g++ protobuf.cpp -std=c++14 -lprotobuf -o a.out
```
**PB的另一个特点是数据有“模式”schema**必须要先写一个IDLInterface Description Language文件在里面定义好数据结构只有预先定义了的数据结构才能被序列化和反序列化。
这个特点既有好处也有坏处:一方面,接口就是清晰明确的规范文档,沟通交流简单无歧义;而另一方面,就是缺乏灵活性,改接口会导致一连串的操作,有点繁琐。
下面是一个简单的PB定义
```
syntax = &quot;proto2&quot;; // 使用第2版
package sample; // 定义名字空间
message Vendor // 定义消息
{
required uint32 id = 1; // required表示必须字段
required string name = 2; // 有int32/string等基本类型
required bool valid = 3; // 需要指定字段的序号,序列化时用
optional string tel = 4; // optional字段可以没有
}
```
有了接口定义文件需要再用protoc工具生成对应的C++源码,然后把源码文件加入自己的项目中,就可以使用了:
```
protoc --cpp_out=. sample.proto // 生成C++代码
```
由于PB相关的资料实在太多了这里我就只简单说一下重要的接口
- 字段名会生成对应的has/set函数检查是否存在和设置值
- IsInitialized()检查数据是否完整required字段必须有值
- DebugString()输出数据的可读字符串描述;
- ByteSize()返回序列化数据的长度;
- SerializeToString()从对象序列化到字符串;
- ParseFromString()从字符串反序列化到对象;
- SerializeToArray()/ParseFromArray()序列化的目标是字节数组。
下面的代码示范了PB的用法
```
using vendor_t = sample::Vendor; // 类型别名
vendor_t v; // 声明一个PB对象
assert(!v.IsInitialized()); // required等字段未初始化
v.set_id(1); // 设置每个字段的值
v.set_name(&quot;sony&quot;);
v.set_valid(true);
assert(v.IsInitialized()); // required等字段都设置了数据完整
assert(v.has_id() &amp;&amp; v.id() == 1);
assert(v.has_name() &amp;&amp; v.name() == &quot;sony&quot;);
assert(v.has_valid() &amp;&amp; v.valid());
cout &lt;&lt; v.DebugString() &lt;&lt; endl; // 输出调试字符串
string enc;
v.SerializeToString(&amp;enc); // 序列化到字符串
vendor_t v2;
assert(!v2.IsInitialized());
v2.ParseFromString(enc); // 反序列化
```
虽然业界很多大厂都在使用PB但我觉得它真不能算是最好的IDL定义和接口都太死板生硬还只能用最基本的数据类型不支持标准容器在现代C++里显得“不太合群”,用起来有点别扭。
不过它后面有Google“撑腰”而且最近几年又有gRPC“助拳”所以很多时候也不得不用。
PB的另一个缺点是官方支持的编程语言太少通用性较差最常用的proto2只有C++、Java和Python。后来的proto3增加了对Go、Ruby等的支持但仍然不能和JSON、MessagePack相比。
## 小结
好了今天我讲了三种数据交换格式JSON、MessagePack和ProtoBuffer。
这三种数据格式各有特色,在很多领域都得到了广泛的应用,我来简单小结一下:
1. JSON是纯文本容易阅读方便编辑适用性最广
1. MessagePack是二进制小巧高效在开源界接受程度比较高
1. ProtoBuffer是工业级的数据格式注重安全和性能多用在大公司的商业产品里。
有很多开源库支持这些数据格式,官方的、民间的都有,你应该选择适合自己的高质量库,必要的时候可以做些测试。
再补充一点除了今天说的这三种你还可以尝试其他的数据格式比较知名的有Avro、Thrift虽然它们有点冷门但也有自己的独到之处比如天生支持RPC、可选择多种序列化格式和传输方式
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. 为什么要有序列化和反序列化直接memcpy内存数据行不行呢
1. 你最常用的是哪种数据格式?它有什么优缺点?
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友。我们下节课见。
<img src="https://static001.geekbang.org/resource/image/a3/f1/a39719e615f1124d60b5b9ca51b88cf1.png" alt="">

View File

@@ -0,0 +1,325 @@
<audio id="audio" title="16 | 网络通信我不想写原生Socket" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/42/eb/42cbb0fe4bd124865b40e29a6fed73eb.mp3"></audio>
你好我是Chrono。
在上一节课我讲了JSON、MessagePack和ProtoBuffer这三种数据交换格式。现在我们手里有了这些跨语言、跨平台的通用数据该怎么与外部通信交换呢
你肯定首先想到的就是Socket网络编程使用TCP/IP协议栈收发数据这样不仅可以在本地的进程间通信也可以在主机、机房之间异地通信。
大方向上这是没错的但你也肯定知道原生的Socket API非常底层要考虑很多细节比如TIME_WAIT、CLOSE_WAIT、REUSEADDR等如果再加上异步就更复杂了。
虽然你可能看过、学过不少这方面的资料对如何处理这些问题“胸有成竹”但无论如何像Socket建连/断连、协议格式解析、网络参数调整等,都要自己动手做,想要“凭空”写出一个健壮可靠的网络应用程序还是相当麻烦的。
所以今天我就来谈谈C++里的几个好用的网络通信库libcurl、cpr和ZMQ让你摆脱使用原生Socket编程的烦恼。
## libcurl高可移植、功能丰富的通信库
第一个要说的库是libcurl它来源于著名的[curl项目](https://curl.haxx.se/)也是curl的底层核心。
libcurl经过了多年的开发和实际项目的验证非常稳定可靠拥有上百万的用户其中不乏Apple、Facebook、Google、Netflix等大公司。
它最早只支持HTTP协议但现在已经扩展到支持所有的应用层协议比如HTTPS、FTP、LDAP、SMTP等功能强大。
libcurl使用纯C语言开发兼容性、可移植性非常好基于C接口可以很容易写出各种语言的封装所以Python、PHP等语言都有libcurl相关的库。
因为C++兼容C所以我们也可以在C++程序里直接调用libcurl来收发数据。
在使用libcurl之前你需要用apt-get或者yum等工具安装开发库
```
apt-get install libcurl4-openssl-dev
```
虽然libcurl支持很多协议但最常用的还是HTTP。所以接下来我也主要介绍libcurl的HTTP使用方法这样对其他的协议你也可以做到“触类旁通”。
libcurl的接口可以粗略地分成两大类easy系列和multi系列。其中easy系列是同步调用比较简单multi系列是异步的多线程调用比较复杂。通常情况下我们用easy系列就足够了。
使用libcurl收发HTTP数据的基本步骤有4个
1. 使用curl_easy_init()创建一个句柄类型是CURL*。但我们完全没有必要关心句柄的类型直接用auto推导就行。
1. 使用curl_easy_setopt()设置请求的各种参数比如请求方法、URL、header/body数据、超时、回调函数等。这是最关键的操作。
1. 使用curl_easy_perform()发送数据,返回的数据会由回调函数处理。
1. 使用curl_easy_cleanup()清理句柄相关的资源,结束会话。
下面我用个简短的例子来示范一下这4步
```
#include &lt;curl/curl.h&gt; // 包含头文件
auto curl = curl_easy_init(); // 创建CURL句柄
assert(curl);
curl_easy_setopt(curl, CURLOPT_URL, &quot;http://nginx.org&quot;); // 设置请求URI
auto res = curl_easy_perform(curl); // 发送数据
if (res != CURLE_OK) { // 检查是否执行成功
cout &lt;&lt; curl_easy_strerror(res) &lt;&lt; endl;
}
curl_easy_cleanup(curl); // 清理句柄相关的资源
```
这段代码非常简单重点是调用curl_easy_setopt()设置了URL请求Nginx官网的首页其他的都使用默认值即可。
由于没有设置你自己的回调函数所以libcurl会使用内部的默认回调把得到的HTTP响应数据输出到标准流也就是直接打印到屏幕上。
这个处理结果显然不是我们所期待的所以如果想要自己处理返回的HTTP报文就得写一个回调函数在里面实现业务逻辑。
因为libcurl是C语言实现的所以回调函数必须是函数指针。不过C++11允许你写lambda表达式这利用了一个特别规定**无捕获的lambda表达式可以显式转换成一个函数指针**。注意一定要是“无捕获”也就是说lambda引出符“[]”必须是空的,不能捕获任何外部变量。
所以只要多做一个简单的转型动作你就可以用lambda表达式直接写libcurl的回调还是熟悉的函数式编程风格
```
// 回调函数的原型
size_t write_callback(char* , size_t , size_t , void* );
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, // 设置回调函数
(decltype(&amp;write_callback)) // decltype获取函数指针类型显式转换
[](char *ptr, size_t size, size_t nmemb, void *userdata)// lambda
{
cout &lt;&lt; &quot;size = &quot; &lt;&lt; size * nmemb &lt;&lt; endl; // 简单的处理
return size * nmemb; // 返回接收的字节数
}
);
```
libcurl的用法大概就是这个样子了开头的准备和结尾的清理工作都很简单关键的就是curl_easy_setopt()这一步的参数设置。我们必须通过查文档知道该用哪些标志宏,写一些单调重复的代码。
你可能想到了可以自己用C++包装出一个类就能够少敲点键盘。但不要着急因为我们有一个更好的选择就是cpr。
## cpr更现代、更易用的通信库
cpr是对libcurl的一个C++11封装使用了很多现代C++的高级特性对外的接口模仿了Python的requests库非常简单易用。
你可以从[GitHub](https://github.com/whoshuu/cpr)上获取cpr的源码再用cmake编译安装
```
git clone git@github.com:whoshuu/cpr.git
cmake . -DUSE_SYSTEM_CURL=ON -DBUILD_CPR_TESTS=OFF
make &amp;&amp; make install
```
和libcurl相比cpr用起来真的是太轻松了不需要考虑什么初始化、设置参数、清理等杂事一句话就能发送HTTP请求
```
#include &lt;cpr/cpr.h&gt; // 包含头文件
auto res = cpr::Get( // GET请求
cpr::Url{&quot;http://openresty.org&quot;} // 传递URL
);
```
你也不用写回调函数HTTP响应就是函数的返回值用成员变量url、header、status_code、text就能够得到报文的各个组成部分
```
cout &lt;&lt; res.elapsed &lt;&lt; endl; // 请求耗费的时间
cout &lt;&lt; res.url &lt;&lt; endl; // 请求的URL
cout &lt;&lt; res.status_code &lt;&lt; endl; // 响应的状态码
cout &lt;&lt; res.text.length() &lt;&lt; endl; // 响应的body数据
for(auto&amp; x : res.header) { // 响应的头字段
cout &lt;&lt; x.first &lt;&lt; &quot;=&gt;&quot; // 类似map的结构
&lt;&lt; x.second &lt;&lt; endl;
}
```
在cpr里HTTP协议的概念都被实现为相应的函数或者类内部再转化为libcurl操作主要的有
- GET/HEAD/POST等请求方法使用同名的Get/Head/Post函数
- URL使用Url类它其实是string的别名
- URL参数使用Parameters类KV结构近似map
- 请求头字段使用Header类它其实是map的别名使用定制的函数实现了大小写无关比较
- Cookie使用Cookies类也是KV结构近似map
- 请求体使用Body类
- 超时设置使用Timeout类。
这些函数和类的用法都非常自然、符合思维习惯而且因为可以使用C++11的花括号“{}”初始化语法如果你以前用过Python reqeusts库的话一定会感到很亲切
```
const auto url = &quot;http://openresty.org&quot;s; // 访问的URL
auto res1 = cpr::Head( // 发送HEAD请求
cpr::Url{url} // 传递URL
);
auto res2 = cpr::Get( // 发送GET请求
cpr::Url{url}, // 传递URL
cpr::Parameters{ // 传递URL参数
{&quot;a&quot;, &quot;1&quot;}, {&quot;b&quot;, &quot;2&quot;}}
);
auto res3 = cpr::Post( // 发送POST请求
cpr::Url{url}, // 传递URL
cpr::Header{ // 定制请求头字段
{&quot;x&quot;, &quot;xxx&quot;},{&quot;expect&quot;,&quot;&quot;}},
cpr::Body{&quot;post data&quot;}, // 传递body数据
cpr::Timeout{200ms} // 超时时间
);
```
cpr也支持异步处理但它内部没有使用libcurl的multi接口而是使用了标准库里的future和async参见[第14讲](https://time.geekbang.org/column/article/245259)和libcurl的实现相比既简单又好理解。
异步接口与同步接口的调用方式基本一样只是名字多了个“Async”的后缀返回的是一个future对象。你可以调用wait()或者get()来获取响应结果:
```
auto f = cpr::GetAsync( // 异步发送GET请求
cpr::Url{&quot;http://openresty.org&quot;}
);
auto res = f.get(); // 等待响应结果
cout &lt;&lt; res.elapsed &lt;&lt; endl; // 请求耗费的时间
```
看了上面这些介绍你是不是有些心动了。说实话我原来在C++里也是一直用libcurl也写过自己的包装类直到发现了cpr这个“大杀器”就立即“弃暗投明”了。
相信有了cpr你今后在C++里写HTTP应用就不再是痛苦而是一种享受了。
## ZMQ高效、快速、多功能的通信库
libcurl和cpr处理的都是HTTP协议虽然用起来很方便但协议自身也有一些限制比如必须要一来一回必须点对点直连在超大数据量通信的时候就不是太合适。
还有一点libcurl和cpr只能充当HTTP的客户端如果你想写服务器端程序这两个工具就完全派不上用场。
所以我们就需要一个更底层、更灵活的网络通信工具它应该能够弥补libcurl和cpr的不足不仅快速高效还能同时支持客户端和服务器端编程。
这就是我要说的第三个库:[ZMQ](https://zeromq.org/)。
其实ZMQ不仅是一个单纯的网络通信库更像是一个高级的异步并发框架。
从名字上就可以看出来Zero Message Queue——零延迟的消息队列意味着它除了可以收发数据外还可以用作消息中间件解耦多个应用服务之间的强依赖关系搭建高效、有弹性的分布式系统从而超越原生的Socket。
作为消息队列ZMQ的另一大特点是零配置零维护零成本不需要搭建额外的代理服务器只要安装了开发库就能够直接使用相当于把消息队列功能直接嵌入到你的应用程序里
```
apt-get install libzmq3-dev
```
ZMQ是用C++开发的但出于兼容的考虑对外提供的是纯C接口。不过它也有很多C++封装,这里我选择的是自带的[cppzmq](https://github.com/zeromq/cppzmq),虽然比较简单,但也基本够用了。
由于ZMQ把自身定位于更高层次的“异步消息队列”所以它的用法就不像Socket、HTTP那么简单直白而是定义了5种不同的工作模式来适应实际中常见的网络通信场景。
我来大概说一下这5种模式
- 原生模式RAW没有消息队列功能相当于底层Socket的简单封装
- 结对模式PAIR两个端点一对一通信
- 请求响应模式REQ-REP也是两个端点一对一通信但请求必须有响应
- 发布订阅模式PUB-SUB一对多通信一个端点发布消息多个端点接收处理
- 管道模式PUSH-PULL或者叫流水线可以一对多也可以多对一。
前四种模式类似HTTP协议、Client-Server架构很简单就不多说了。我拿我在工作中比较常用的管道模式来给你示范一下ZMQ的用法它非常适合进程间无阻塞传送海量数据也有点map-reduce的意思。
<img src="https://static001.geekbang.org/resource/image/8b/2d/8b868214b032aebfcee1e0e97a8a1e2d.png" alt="">
在ZMQ里有两个基本的类。
- 第一个是context_t它是ZMQ的运行环境。使用ZMQ的任何功能前必须要先创建它。
- 第二个是socket_t表示ZMQ的套接字需要指定刚才说的那5种工作模式。注意它与原生Socket没有任何关系只是借用了名字来方便理解。
下面的代码声明了一个全局的ZMQ环境变量并定义了一个lambda表达式生产ZMQ套接字
```
const auto thread_num = 1; // 并发线程数
zmq::context_t context(thread_num); // ZMQ环境变量
auto make_sock = [&amp;](auto mode) // 定义一个lambda表达式
{
return zmq::socket_t(context, mode); // 创建ZMQ套接字
};
```
和原生Socket一样ZMQ套接字也必须关联到一个确定的地址才能收发数据但它不仅支持TCP/IP还支持进程内和进程间通信这在本机交换数据时会更高效
- TCP通信地址的形式是“tcp://…”指定IP地址和端口号
- 进程内通信地址的形式是“inproc://…”,指定一个本地可访问的路径;
- 进程间通信地址的形式是“ipc://…”,也是一个本地可访问的路径。
用bind()/connect()这两个函数把ZMQ套接字连接起来之后就可以用send()/recv()来收发数据了,看一下示例代码吧:
```
const auto addr = &quot;ipc:///dev/shm/zmq.sock&quot;s; // 通信地址
auto receiver = [=]() // lambda表达式接收数据
{
auto sock = make_sock(ZMQ_PULL); // 创建ZMQ套接字拉数据
sock.bind(addr); // 绑定套接字
assert(sock.connected());
zmq::message_t msg;
sock.recv(&amp;msg); // 接收消息
string s = {msg.data&lt;char&gt;(), msg.size()};
cout &lt;&lt; s &lt;&lt; endl;
};
auto sender = [=]() // lambda表达式发送数据
{
auto sock = make_sock(ZMQ_PUSH); // 创建ZMQ套接字推数据
sock.connect(addr); // 连接到对端
assert(sock.connected());
string s = &quot;hello zmq&quot;;
sock.send(s.data(), s.size()); // 发送消息
};
```
这段代码实现了两个最基本的客户端和服务器看起来好像没什么特别的。但你应该注意到使用ZMQ完全不需要考虑底层的TCP/IP通信细节它会保证消息异步、安全、完整地到达服务器让你关注网络通信之上更有价值的业务逻辑。
ZMQ的用法就是这么简单但想要进一步发掘它的潜力处理大流量的数据还是要去看[它的文档](http://wiki.zeromq.org/),选择合适的工作模式,再仔细调节各种参数。
接下来,我再给你分享两个实际工作中会比较有用的细节吧。
一个是**ZMQ环境的线程数**。它的默认值是1太小了适当增大一些就可以提高ZMQ的并发处理能力。我一般用的是4~6具体设置为多少最好还是通过性能测试来验证下。
另一个是**收发消息时的本地缓存数量**ZMQ的术语叫High Water Mark。如果收发的数据过多数量超过HWMZMQ要么阻塞要么丢弃消息。
HWM需要调用套接字的成员函数setsockopt()来设置,注意收发使用的是两个不同的标志:
```
sock.setsockopt(ZMQ_RCVHWM, 1000); // 接收消息最多缓存1000条
sock.setsockopt(ZMQ_SNDHWM, 100); // 发送消息最多缓存100条
```
我们把HWM设置成多大都可以比如我就曾经在一个高并发系统里用过100万以上的值不用担心ZMQ会把一切都处理得很好。
关于ZMQ就暂时说到这里它还有很多强大的功能你可以阅读[官网](http://zguide.zeromq.org/page:all)上的教程和指南里面非常详细地讨论了ZMQ的各种模式和要点。
## 小结
好了,我来给今天的内容做一个小结:
1. libcurl是一个功能完善、稳定可靠的应用层通信库最常用的就是HTTP协议
1. cpr是对libcurl的C++封装,接口简单易用;
1. libcurl和cpr都只能作为客户端来使用不能编写服务器端应用
1. ZMQ是一个高级的网络通信库支持多种通信模式可以把消息队列功能直接嵌入应用程序搭建出高效、灵活、免管理的分布式系统。
最后再说说即将到来的C++20原本预计会加入期待已久的networking库但现在已经被推迟到了下一个版本C++23
networking库基于已有多年实践的boost.asio采用前摄器模式Proactor统一封装了操作系统的各种异步机制epoll、kqueue、IOCP而且支持协程。有了它我们的网络通信工作就会更加轻松。
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. 你在网络编程的时候都遇到过哪些“坑”,今天说的这几个库能否解决你的问题?
1. 你觉得ZMQ能够在多大程度上代替原生Socket
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友。我们下节课见。
<img src="https://static001.geekbang.org/resource/image/3e/dd/3e07516e87c61172f9b2ddc317c74add.jpg" alt="">

View File

@@ -0,0 +1,367 @@
<audio id="audio" title="17 | 脚本语言:搭建高性能的混合系统" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a4/d6/a4cd64d4600479f1c11aaf9ae8e25ed6.mp3"></audio>
你好我是Chrono。
经过了前面这么多节课的学习相信你已经认识到了C++的高效、灵活和强大。使用现代特性再加上标准库和第三方库C++几乎“无所不能”。
但是C++也有自己的“阿喀琉斯之踵”,那就是语言复杂、学习曲线陡峭、开发周期长、排错/维护成本高。
所以C++不能完全适应现在的快速开发和迭代的节奏最终只能退到后端、底层等领域。要想充分发挥C++的功力,就要辅助其他的语言搭建混合系统,尽量扬长避短,做好那最关键、最核心的部分,这样才能展现出它应有的价值。
由于当前的操作系统、虚拟机、解释器、引擎很多都是用C或者C++编写的所以使用C++可以很容易地编写各种底层模块为上层的Java、Go等语言提供扩展功能。
不过今天我不去说这些大型语言而是讲两种轻便的脚本语言Python和Lua看看C++怎么和它们俩实现无缝对接以C++为底层基础Python和Lua作为上层建筑共同搭建起高性能、易维护、可扩展的混合系统。
## Python
Python应该是除了JavaScript以外最流行的一种脚本语言了一直在TIOBE榜单里占据前三名的位置。而且在新兴的大数据、人工智能、科学计算等领域也都有着广泛的应用。很多大公司都长期招聘Python程序员就是看中了它的高生产率。
Python本身就有C接口可以用C语言编写扩展模块把一些低效耗时的功能改用C实现有的时候会把整体性能提升几倍甚至几十倍。
但是使用纯C语言写扩展模块非常麻烦那么能不能利用C++的那些高级特性来简化这部分的工作呢?
很多人都想到了这个问题于是就出现了一些专门的C++/Python工具使用C++来开发Python扩展。其中我认为最好的一个就是[pybind11](https://github.com/pybind/pybind11)。
pybind11借鉴了“前辈”Boost.Python能够在C++和Python之间自由转换任意翻译两者的语言要素比如把C++的vector转换为Python的列表把Python的元组转换为C++的tuple既可以在C++里调用Python脚本也可以在Python里调用C++的函数、类。
pybind11名字里的“11”表示它完全基于现代C++开发C++11以上所以没有兼容旧系统的负担。它使用了大量的现代C++特性,不仅代码干净整齐,运行效率也更高。
下面我就带你看看怎么用pybind11让C++来辅助Python提升Python的性能。
pybind11是一个纯头文件的库但因为必须结合Python所以首先要有Python的开发库然后再用pip工具安装。
pybind11支持Python2.7、Python3和PyPy这里我用的是Python3
```
apt-get install python3-dev
apt-get install python3-pip
pip3 install pybind11
```
pybind11充分利用了C++预处理和模板元编程把原本无聊重复的代码都隐藏了起来展现了“神奇的魔法”——只需要短短几行代码就可以实现一个Python扩展模块。具体怎么实现呢
实际上,你只要用一个宏“**PYBIND11_MODULE**”再给它两个参数Python模块名和C++实例对象名,就可以了。
```
#include &lt;pybind11/pybind11.h&gt; // pybind11的头文件
PYBIND11_MODULE(pydemo, m) // 定义Python模块pydemo
{
m.doc() = &quot;pybind11 demo doc&quot;; // 模块的说明文档
} // Python模块定义结束
```
代码里的pydemo就是Python里的模块名之后在Python脚本里必须用这个名字才能import。
第二个参数“m”其实是pybind11::module的一个实例对象封装了所有的操作比如这里的doc()就是模块的说明文档。它只是个普通的变量起什么名字都可以但为了写起来方便一般都用“m”。
假设这个C++源文件名是“pybind.cpp”现在你就可以用g++把它编译成在Python里调用的模块了不过编译命令比较复杂
```
g++ pybind.cpp \ #编译的源文件
-std=c++11 -shared -fPIC \ #编译成动态库
`python3 -m pybind11 --includes` \ #获得包含路径
-o pydemo`python3-config --extension-suffix` #生成的动态库名字
```
我来稍微解释一下。第一行是指定编译的源文件第二行是指定编译成动态库这两个不用多说。第三行调用了Python获得pybind11所在的包含路径让g++能够找得到头文件。第四行最关键,是生成的动态库名字,**前面必须是源码里的模块名**而后面那部分则是Python要求的后缀名否则Python运行时会找不到模块。
编译完后会生成一个大概这样的文件pydemo.cpython-35m-x86_64-linux-gnu.so现在就可以在Python里验证了使用import导入然后用help就能查看模块说明
```
$ python3
&gt;&gt;&gt; import pydemo
&gt;&gt;&gt; help(pydemo)
```
刚才的代码非常简单只是个空模块里面什么都没有现在我们来看看怎么把C++的函数导入Python。
你需要用的是**def()函数**传递一个Python函数名和C++的函数、函数对象或者是lambda表达式形式上和Python的函数也差不多
```
namespace py = pybind11; // 名字空间别名,简化代码
PYBIND11_MODULE(pydemo, m) // 定义Python模块pydemo
{
m.def(&quot;info&quot;, // 定义Python函数
[]() // 定义一个lambda表达式
{
py::print(&quot;c++ version =&quot;, __cplusplus); // pybind11自己的打印函数
py::print(&quot;gcc version =&quot;, __VERSION__);
py::print(&quot;libstdc++ =&quot;, __GLIBCXX__);
}
);
m.def(&quot;add&quot;, // 定义Python函数
[](int a, int b) // 有参数的lambda表达式
{
return a + b;
}
);
} // Python模块定义结束
```
这样我们就非常轻松地实现了两个Python函数在Python里可以验证效果
```
import pydemo # 导入pybind11模块
pydemo.info() # 调用C++写的函数
x = pydemo.add(1,2) # 调用C++写的函数
```
pybind11也支持函数的参数、返回值使用标准容器会自动转换成Python里的list、dict不过你需要额外再包含一个“stl.h”的头文件。
下面的示例代码演示了C++的string、tuple和vector是如何用于Python的
```
#include &lt;pybind11/stl.h&gt; // 转换标准容器必须的头文件
PYBIND11_MODULE(pydemo, m) // 定义Python模块pydemo
{
m.def(&quot;use_str&quot;, // 定义Python函数
[](const string&amp; str) // 入参是string
{
py::print(str);
return str + &quot;!!&quot;; // 返回string
}
);
m.def(&quot;use_tuple&quot;, // 定义Python函数
[](tuple&lt;int, int, string&gt; x) // 入参是tuple
{
get&lt;0&gt;(x)++;
get&lt;1&gt;(x)++;
get&lt;2&gt;(x)+= &quot;??&quot;;
return x; // 返回元组
}
);
m.def(&quot;use_list&quot;, // 定义Python函数
[](const vector&lt;int&gt;&amp; v) // 入参是vector
{
auto vv = v;
py::print(&quot;input :&quot;, vv);
vv.push_back(100);
return vv; // 返回列表
}
);
}
```
因为都是面向对象的编程语言C++里的类也能够等价地转换到Python里面调用这要用到一个特别的模板类class_注意它有意模仿了关键字class后面多了一个下划线。
我拿一个简单的Point类来举个例子
```
class Point final
{
public:
Point() = default;
Point(int a)
public:
int get() const;
void set(int a);
};
```
使用pybind11你需要在模板参数里写上这个类名然后在构造函数里指定它在Python里的名字。
导出成员函数还是调用函数def(),但它会返回对象自身的引用,所以就可以连续调用,在一句话里导出所有接口:
```
py::class_&lt;Point&gt;(m, &quot;Point&quot;) // 定义Python类
.def(py::init()) // 导出构造函数
.def(py::init&lt;int&gt;()) // 导出构造函数
.def(&quot;get&quot;, &amp;Point::get) // 导出成员函数
.def(&quot;set&quot;, &amp;Point::set) // 导出成员函数
;
```
对于一般的成员函数来说,定义的方式和普通函数一样,只是你必须加上取地址操作符“&amp;把它写成函数指针的形式。而构造函数则比较特殊必须调用init()函数来表示如果有参数还需要在init()函数的模板参数列表里写清楚。
pybind11的功能非常丰富我们不可能一下子学完全部的功能刚才说的这些只是最基本也是非常实用的功能。除了这些它还支持异常、枚举、智能指针等很多C++特性,你可以再参考一下它的[文档](https://github.com/pybind/pybind11),学习一下具体的方法,挖掘出它的更多价值。
如果你在工作中重度使用Python那么pybind11绝对是你的得力助手它能够让C++紧密地整合进Python应用里让Python跑得更快、更顺畅建议你有机会就尽量多用。
## Lua
接下来我要说的第二个脚本语言是小巧高效的Lua号称是“最快的脚本语言”。
你可能对Lua不太了解但你一定听说过《魔兽世界》《愤怒的小鸟》吧它们就在内部大量使用了Lua来编写逻辑。在游戏开发领域Lua可以说是一种通用的工作语言。
Lua与其他语言最大的不同点在于它的设计目标不追求“大而全”而是“小而美”。Lua自身只有很小的语言核心能做的事情很少。但正是因为它小才能够很容易地嵌入到其他语言里为“宿主”添加脚本编程的能力让“宿主”更容易扩展和定制。
标准的LuaPUC-Rio Lua使用解释器运行速度虽然很快但和C/C++比起来还是有差距的。所以你还可以选择另一个兼容的项目LuaJIT[https://luajit.org/](https://luajit.org/)。它使用了JITJust in time技术能够把Lua代码即时编译成机器码速度几乎可以媲美原生C/C++代码。
不过LuaJIT也有一个问题它是一个个人项目更新比较慢最新的2.1.0-beta3已经是三年前的事情了。所以我推荐你改用它的一个非官方分支OpenResty-LuaJIT[https://github.com/openresty/luajit2](https://github.com/openresty/luajit2)。它由OpenResty负责维护非常活跃修复了很多小错误。
```
git clone git@github.com:openresty/luajit2.git
make &amp;&amp; make install
```
和Python一样Lua也有C接口用来编写扩展模块但因为它比较小众所以C++项目不是很多。现在我用的是LuaBridge虽然它没有用到太多的C++11新特性但也足够好。
LuaBridge是一个纯头文件的库只要下载下来把头文件拷贝到包含路径就能够直接用
```
git clone git@github.com:vinniefalco/LuaBridge.git
```
我们先来看看在Lua里怎么调C++的功能。
和前面说的pybind11类似LuaBridge也定义了很多的类和方法可以把C++函数、类注册到Lua里让Lua调用。
但我不建议你用这种方式因为我们现在有LuaJIT。它内置了一个ffi库Foreign Function Interface能够在Lua脚本里直接声明接口函数、直接调用不需要任何的注册动作更加简单方便。而且这种做法还越过了Lua传统的栈操作速度也更快。
使用ffi唯一要注意的是**它只能识别纯C接口不认识C++**所以写Lua扩展模块的时候内部可以用C++但对外的接口必须转换成纯C函数。
下面我写了一个简单的add()函数还有一个全局变量注意里面必须要用extern "C"声明:
```
extern &quot;C&quot; { // 使用纯C语言的对外接口
int num = 10;
int my_add(int a, int b);
}
int my_add(int a, int b) // 一个简单的函数供Lua调用
{
return a + b;
}
```
然后就可以用g++把它编译成动态库不像pybind11它没有什么特别的选项
```
g++ lua_shared.cpp -std=c++11 -shared -fPIC -o liblua_shared.so
```
在Lua脚本里你首先要用ffi.cdef声明要调用的接口再用ffi.load加载动态库这样就会把动态库所有的接口都引进Lua然后就能随便使用了
```
local ffi = require &quot;ffi&quot; -- 加载ffi库
local ffi_load = ffi.load -- 函数别名
local ffi_cdef = ffi.cdef
ffi_cdef[[ // 声明C接口
int num;
int my_add(int a, int b);
]]
local shared = ffi_load(&quot;./liblua_shared.so&quot;) -- 加载动态库
print(shared.num) -- 调用C接口
local x = shared.my_add(1, 2) -- 调用C接口
```
在ffi的帮助下让Lua调用C接口几乎是零工作量但这并不能完全发挥出Lua的优势。
因为和Python不一样Lua很少独立运行大多数情况下都要嵌入在宿主语言里被宿主调用然后再“回调”底层接口利用它的“胶水语言”特性去粘合业务逻辑。
要在C++里嵌入Lua首先要调用函数**luaL_newstate()**创建出一个Lua虚拟机所有的Lua功能都要在它上面执行。
因为Lua是用C语言写的Lua虚拟机用完之后必须要用函数**lua_close()**关闭所以最好用RAII技术写一个类来自动管理。可惜的是LuaBridge没有对此封装所以只能自己动手了。这里我用了智能指针shared_ptr在一个lambda表达式里创建虚拟机顺便再打开Lua基本库
```
auto make_luavm = []() // lambda表达式创建虚拟机
{
std::shared_ptr&lt;lua_State&gt; vm( // 智能指针
luaL_newstate(), lua_close // 创建虚拟机对象,设置删除函数
);
luaL_openlibs(vm.get()); // 打开Lua基本库
return vm;
};
#define L vm.get() // 获取原始指针,宏定义方便使用
```
在LuaBridge里一切Lua数据都被封装成了**LuaRef**类完全屏蔽了Lua底层那难以理解的栈操作。它可以隐式或者显式地转换成对应的数字、字符串等基本类型如果是表就可以用“[]”访问成员,如果是函数,也可以直接传参调用,非常直观易懂。
使用LuaBridge访问Lua数据时还要注意一点它只能用函数**getGlobal()**看到全局变量所以如果想在C++里调用Lua功能就一定不能加“local”修饰。
给你看一小段代码它先创建了一个Lua虚拟机然后获取了Lua内置的package模块输出里面的默认搜索路径path和cpath
```
auto vm = make_luavm(); // 创建Lua虚拟机
auto package = getGlobal(L, &quot;package&quot;); // 获取内置的package模块
string path = package[&quot;path&quot;]; // 默认的lua脚本搜索路径
string cpath = package[&quot;cpath&quot;]; // 默认的动态库搜索路径
```
你还可以调用**luaL_dostring()和luaL_dofile()**这两个函数直接执行Lua代码片段或者外部的脚本文件。注意luaL_dofile()每次调用都会从磁盘载入文件所以效率较低。如果是频繁调用最好把代码读进内存存成一个字符串再用luaL_dostring()运行:
```
luaL_dostring(L, &quot;print('hello lua')&quot;); // 执行Lua代码片段
luaL_dofile(L, &quot;./embedded.lua&quot;); // 执行外部的脚本文件
```
在C++里嵌入Lua还有另外一种方式**提前在脚本里写好一些函数加载后在C++里逐个调用**,这种方式比执行整个脚本更灵活。
具体的做法也很简单先用luaL_dostring()或者luaL_dofile()加载脚本然后调用getGlobal()从全局表里获得封装的LuaRef对象就可以像普通函数一样执行了。由于Lua是动态语言变量不需要显式声明类型所以写起来就像是C++的泛型函数,但却更简单:
```
string chunk = R&quot;( -- Lua代码片段
function say(s) -- Lua函数1
print(s)
end
function add(a, b) -- Lua函数2
return a + b
end
)&quot;;
luaL_dostring(L, chunk.c_str()); // 执行Lua代码片段
auto f1 = getGlobal(L, &quot;say&quot;); // 获得Lua函数
f1(&quot;say something&quot;); // 执行Lua函数
auto f2 = getGlobal(L, &quot;add&quot;); // 获得Lua函数
auto v = f2(10, 20); // 执行Lua函数
```
只要掌握了上面的这些基本用法并合理地划分出C++与Lua的职责边界就可以搭建出“LuaJIT + LuaBridge + C++”的高性能应用运行效率与开发效率兼得。比如说用C++写底层的框架、引擎暴露出各种调用接口作为“业务零件”再用灵活的Lua脚本去组合这些“零件”写上层的业务逻辑。
## 小结
好了今天我讲了怎么基于C++搭建混合系统介绍了Python和Lua这两种脚本语言。
Python很“大众”但比较复杂、性能不是特别高而Lua比较“小众”很小巧有LuaJIT让它运行速度极快。你可以结合自己的实际情况来选择比如语言的熟悉程度、项目的功能/性能需求、开发的难易度,等等。
今天的内容也比较多,我简单小结一下要点:
1. C++高效、灵活,但开发周期长、成本高,在混合系统里可以辅助其他语言,编写各种底层模块提供扩展功能,从而扬长避短;
1. pybind11是一个优秀的C++/Python绑定库只需要写很简单的代码就能够把函数、类等C++要素导入Python
1. Lua是另一种小巧快速的脚本语言它的兼容项目LuaJIT速度更快
1. 使用LuaBridge可以导出C++的函数、类但直接用LuaJIT的ffi库更好
1. 使用LuaBridge也可以很容易地执行Lua脚本、调用Lua函数让Lua跑在C++里。
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. 你觉得使用脚本语言与C++搭建混合系统有什么优势?
1. 你觉得“把C++嵌入脚本语言”和“把脚本语言嵌入C++”有什么区别,哪种方式更好?
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友。我们下节课见。
<img src="https://static001.geekbang.org/resource/image/e4/2b/e47906e7f83ec210cc011e2652eee12b.jpg" alt="">

View File

@@ -0,0 +1,239 @@
<audio id="audio" title="18 | 性能分析:找出程序的瓶颈" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/aa/e5/aa37739f0b24085935bc2d189bc079e5.mp3"></audio>
你好我是Chrono。
今天是“技能进阶”单元的最后一节课,我也要兑现刚开始在“概论”里的承诺,讲一讲在运行阶段我们能做什么。
## 运行阶段能做什么
在编码阶段你会运用之前学习的各种范式和技巧写出优雅、高效的代码然后把它交给编译器。经过预处理和编译这两个阶段源码转换成了二进制的可执行程序就能够在CPU上“跑”起来。
在运行阶段C++静态程序变成了动态进程是一个实时、复杂的状态机由CPU全程掌控。但因为CPU的速度实在太快程序的状态又实在太多所以前几个阶段的思路、方法在这个时候都用不上。
所以,我认为,在运行阶段能做、应该做的事情主要有三件:**调试**Debug**、测试**Test**和性能分析**Performance Profiling
调试你一定很熟悉了常用的工具是GDB我在前面的“[轻松话题](https://time.geekbang.org/column/article/239599)”里也讲过一点它的使用技巧。它的关键是让高速的CPU慢下来把它降速到和人类大脑一样的程度于是我们就可以跟得上CPU的节奏理清楚程序的动态流程。
测试的目标是检验程序的功能和性能保证软件的质量它与调试是相辅相成的关系。测试发现Bug调试去解决Bug再返回给测试验证。好的测试对于软件的成功至关重要有很多现成的测试理论、应用、系统你可以参考下我就不多说了
一般来说,程序经过调试和测试这两个步骤,就可以上线运行了,进入第三个、也是最难的性能分析阶段。
什么是性能分析呢?
你可以把它跟Code Review对比一下。Code Review是一种静态的程序分析方法在编码阶段通过观察源码来优化程序、找出隐藏的Bug。而性能分析是一种动态的程序分析方法在运行阶段采集程序的各种信息再整合、研究找出软件运行的“瓶颈”为进一步优化性能提供依据指明方向。
从这个粗略的定义里,你可以看到,性能分析的关键就是“**测量**”,用数据说话。没有实际数据的支撑,优化根本无从谈起,即使做了,也只能是漫无目的的“不成熟优化”,即使成功了,也只是“瞎猫碰上死耗子”而已。
性能分析的范围非常广可以从CPU利用率、内存占用率、网络吞吐量、系统延迟等许多维度来评估。
今天我只讲多数时候最看重的CPU性能分析。因为CPU利用率通常是评价程序运行的好坏最直观、最容易获取的指标优化它是提升系统性能最快速的手段。而其他的几个维度也大多与CPU分析相关可以达到“以点带面”的效果。
## 系统级工具
刚才也说了,性能分析的关键是测量,而测量就需要使用工具,那么,你该选什么、又该怎么用工具呢?
其实Linux系统自己就内置了很多用于性能分析的工具比如top、sar、vmstat、netstat等等。但是Linux的性能分析工具太多、太杂有点“乱花渐欲迷人眼”的感觉想要学会并用在实际项目里不狠下一番功夫是不行的。
所以为了让你能够快速入门性能分析我根据我这些年的经验挑选了四个“高性价比”的工具top、pstack、strace和perf。它们用起来很简单而且实用性很强可以观测到程序的很多外部参数和内部函数调用由内而外、由表及里地分析程序性能。
第一个要说的是“**top**”它通常是性能分析的“起点”。无论你开发的是什么样的应用程序敲个top命令就能够简单直观地看到CPU、内存等几个最关键的性能指标。
top展示出来的各项指标的含义都非常丰富我来说几个操作要点吧帮助你快速地抓住它的关键信息。
一个是按“M”看内存占用RES/MEM另一个是按“P”看CPU占用这两个都会从大到小自动排序方便你找出最耗费资源的进程。
另外你也可以按组合键“xb”然后用“&lt;&gt;”手动选择排序的列,这样查看起来更自由。
我曾经做过一个“魔改”Nginx的实际项目下面的这个截图展示的就是一次top查看的性能
<img src="https://static001.geekbang.org/resource/image/6a/a8/6a44808ccc8b1df7bef0a51c888ce2a8.png" alt="">
从top的输出结果里你可以看到进程运行的概况知道CPU、内存的使用率。如果你发现某个指标超出了预期就说明可能存在问题接下来你就应该采取更具体的措施去进一步分析。
比如说这里面的一个进程CPU使用率太高我怀疑有问题那我就要深入进程内部看看到底是哪些操作消耗了CPU。
这时,我们可以选用两个工具:**pstack和strace**。
pstack可以打印出进程的调用栈信息有点像是给正在运行的进程拍了个快照你能看到某个时刻的进程里调用的函数和关系对进程的运行有个初步的印象。
下面这张截图显示了一个进程的部分调用栈可以看到跑了好几个ZMQ的线程在收发数据
<img src="https://static001.geekbang.org/resource/image/6c/9c/6c115ce03d6b4803960277468cf91b9c.png" alt="">
不过pstack显示的只是进程的一个“静态截面”信息量还是有点少而strace可以显示出进程的正在运行的系统调用实时查看进程与系统内核交换了哪些信息
<img src="https://static001.geekbang.org/resource/image/b7/f0/b747d0d977c7f420507ec9e9d84e6ff0.png" alt="">
把pstack和strace结合起来你大概就可以知道进程在用户空间和内核空间都干了些什么。当进程的CPU利用率过高或者过低的时候我们有很大概率能直接发现瓶颈所在。
不过,有的时候,你也可能会“一无所获”,毕竟这两个工具获得的信息只是“表象”,数据的“含金量”太低,做不出什么有效的决策,还是得靠“猜”。要拿到更有说服力的“数字”,就得**perf**出场了。
perf可以说是pstack和strace的“高级版”它按照固定的频率去“采样”相当于连续执行多次的pstack然后再统计函数的调用次数算出百分比。只要采样的频率足够大把这些“瞬时截面”组合在一起就可以得到进程运行时的可信数据比较全面地描述出CPU使用情况。
我常用的perf命令是“**perf top -K -p xxx**”按CPU使用率排序只看用户空间的调用这样很容易就能找出最耗费CPU的函数。
比如下面这张图显示的是大部分CPU时间都消耗在了ZMQ库上其中内存拷贝调用居然达到了近30%,是不折不扣的“大户”。所以,只要能把这些拷贝操作减少一点,就能提升不少性能。
<img src="https://static001.geekbang.org/resource/image/55/15/5543dec44c23d23b583bc937213e7c15.png" alt="">
总之,**使用perf通常可以快速定位系统的瓶颈帮助你找准性能优化的方向**。课下你也可以自己尝试多分析各种进程比如Redis、MySQL等等观察它们都在干什么。
## 源码级工具
top、pstack、strace和perf属于“非侵入”式的分析工具不需要修改源码就可以在软件的外部观察、收集数据。它们虽然方便易用但毕竟是“隔岸观火”还是不能非常细致地分析软件效果不是太理想。
所以,我们还需要有“侵入”式的分析工具,在源码里“埋点”,直接写特别的性能分析代码。这样针对性更强,能够有目的地对系统的某个模块做精细化分析,拿到更准确、更详细的数据。
其实,这种做法你并不陌生,比如计时器、计数器、关键节点打印日志,等等,只是通常并没有上升到性能分析的高度,手法比较“原始”。
在这里,我要推荐一个专业的源码级性能分析工具:**Google Performance Tools**一般简称为gperftools。它是一个C++工具集里面包含了几个专门的性能分析工具还有一个高效的内存分配器tcmalloc分析效果直观、友好、易理解被广泛地应用于很多系统经过了充分的实际验证。
```
apt-get install google-perftools
apt-get install libgoogle-perftools-dev
```
gperftools的性能分析工具有CPUProfiler和HeapProfiler两种用来分析CPU和内存。不过如果你听从我的建议总是使用智能指针、标准容器不使用new/delete就完全可以不用关心HeapProfiler。
CPUProfiler的原理和perf差不多也是按频率采样默认是每秒100次100Hz也就是每10毫秒采样一次程序的函数调用情况。
它的用法也比较简单,只需要在源码里添加三个函数:
- **ProfilerStart()**,开始性能分析,把数据存入指定的文件里;
- **ProfilerRegisterThread()**,允许对线程做性能分析;
- **ProfilerStop()**,停止性能分析。
所以你只要把想做性能分析的代码“夹”在这三个函数之间就行运行起来后gperftools就会自动产生分析数据。
为了写起来方便我用shared_ptr实现一个自动管理功能。这里利用了void*和空指针可以在智能指针析构的时候执行任意代码简单的RAII惯用法
```
auto make_cpu_profiler = // lambda表达式启动性能分析
[](const string&amp; filename) // 传入性能分析的数据文件名
{
ProfilerStart(filename.c_str()); // 启动性能分析
ProfilerRegisterThread(); // 对线程做性能分析
return std::shared_ptr&lt;void&gt;( // 返回智能指针
nullptr, // 空指针,只用来占位
[](void*){ // 删除函数执行停止动作
ProfilerStop(); // 停止性能分析
}
);
};
```
下面我写一小段代码,测试正则表达式处理文本的性能:
```
auto cp = make_cpu_profiler(&quot;case1.perf&quot;); // 启动性能分析
auto str = &quot;neir:automata&quot;s;
for(int i = 0; i &lt; 1000; i++) { // 循环一千次
auto reg = make_regex(R&quot;(^(\w+)\:(\w+)$)&quot;);// 正则表达式对象
auto what = make_match();
assert(regex_match(str, what, reg)); // 正则匹配
}
```
注意我特意在for循环里定义了正则对象现在就可以用gperftools来分析一下这样做是不是成本很高。
编译运行后会得到一个“case1.perf”的文件里面就是gperftools的分析数据但它是二进制的不能直接查看如果想要获得可读的信息还需要另外一个工具脚本pprof。
但是pprof脚本并不含在apt-get的安装包里所以你还要从[GitHub](https://github.com/gperftools/gperftools)上下载源码,然后用“`--text`”选项,就可以输出文本形式的分析报告:
```
git clone git@github.com:gperftools/gperftools.git
pprof --text ./a.out case1.perf &gt; case1.txt
Total: 72 samples
4 5.6% 5.6% 4 5.6% __gnu_cxx::__normal_iterator::base
4 5.6% 11.1% 4 5.6% _init
4 5.6% 16.7% 4 5.6% std::vector::begin
3 4.2% 20.8% 4 5.6% __gnu_cxx::operator-
3 4.2% 25.0% 5 6.9% std::__distance
2 2.8% 27.8% 2 2.8% __GI___strnlen
2 2.8% 30.6% 6 8.3% __GI___strxfrm_l
2 2.8% 33.3% 3 4.2% __dynamic_cast
2 2.8% 36.1% 2 2.8% __memset_sse2
2 2.8% 38.9% 2 2.8% operator new[]
```
pprof的文本分析报告和perf的很像也是列出了函数的采样次数和百分比但因为是源码级的采样会看到大量的内部函数细节虽然很详细但很难找出重点。
好在pprof也能输出图形化的分析报告支持有向图和火焰图需要你提前安装Graphviz和FlameGraph
```
apt-get install graphviz
git clone git@github.com:brendangregg/FlameGraph.git
```
然后,你就可以使用“`--svg`”“`--collapsed`”等选项,生成更直观易懂的图形报告了:
```
pprof --svg ./a.out case1.perf &gt; case1.svg
pprof --collapsed ./a.out case1.perf &gt; case1.cbt
flamegraph.pl case1.cbt &gt; flame.svg
flamegraph.pl --invert --color aqua case1.cbt &gt; icicle.svg
```
我就拿最方便的火焰图来“看图说话”吧。你也可以在[GitHub](https://github.com/chronolaw/cpp_study/blob/master/section4/icicle.svg)上找到原图。
<img src="https://static001.geekbang.org/resource/image/75/30/7587a411eb9c7a16f68bd3453a1eec30.png" alt="">
这张火焰图实际上是“倒置”的冰柱图,显示的是自顶向下查看函数的调用栈。
由于C++有名字空间、类、模板等特性函数的名字都很长看起来有点费劲不过这样也比纯文本要直观一些可以很容易地看出正则表达式占用了绝大部分的CPU时间。再仔细观察的话就会发现_Compiler()这个函数是真正的“罪魁祸首”。
找到了问题所在,现在我们就可以优化代码了,把创建正则对象的语句提到循环外面:
```
auto reg = make_regex(R&quot;(^(\w+)\:(\w+)$)&quot;); // 正则表达式对象
auto what = make_match();
for(int i = 0; i &lt; 1000; i++) { // 循环一千次
assert(regex_match(str, what, reg)); // 正则匹配
}
```
再运行程序你会发现程序瞬间执行完毕而且因为优化效果太好gperftools甚至都来不及采样不会产生分析数据。
基本的gperftools用法就这么多了你可以再去看它的[官方文档](https://github.com/gperftools/gperftools/tree/master/docs)了解更多的用法比如使用环境变量和信号来控制启停性能分析或者链接tcmalloc库优化C++的内存分配速度。
## 小结
好了今天主要讲了运行阶段里的性能分析它能够回答为什么系统“不够好”not good enough而调试和测试回答的是为什么系统“不好”not good
简单小结一下今天的内容:
1. 最简单的性能分析工具是top可以快速查看进程的CPU、内存使用情况
1. pstack和strace能够显示进程在用户空间和内核空间的函数调用情况
1. perf以一定的频率采样分析进程统计各个函数的CPU占用百分比
1. gperftools是“侵入”式的性能分析工具能够生成文本或者图形化的分析报告最直观的方式是火焰图。
性能分析与优化是一门艰深的课题也是一个广泛的议题CPU、内存、网络、文件系统、数据库等等每一个方向都可以再引出无数的话题。
今天介绍的这些是我挑选的对初学者最有用的内容学习难度不高容易上手见效快。希望你能以此为契机在今后的日子里多用、多实际操作并且不断去探索、应用其他的分析工具综合运用它们给程序“把脉”才能让C++在运行阶段跑得更好更快更稳,才能不辜负前面编码、预处理和编译阶段的苦心与努力。
## 课下作业
最后还是留两个思考题吧:
1. 你觉得在运行阶段还能够做哪些事情?
1. 你有性能分析的经验吗?听了今天的这节课之后,你觉得什么方式比较适合自己?
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友。我们下节课见。
<img src="https://static001.geekbang.org/resource/image/45/f1/45adfe31c60a89ff54b7dbce366e2bf1.png" alt="">