mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-19 23:53:47 +08:00
del
This commit is contained in:
377
极客时间专栏/geek/罗剑锋的C++实战笔记/技能进阶/15 | 序列化:简单通用的数据交换格式有哪些?.md
Normal file
377
极客时间专栏/geek/罗剑锋的C++实战笔记/技能进阶/15 | 序列化:简单通用的数据交换格式有哪些?.md
Normal 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["age"] = 23; // "age":23
|
||||
j["name"] = "spiderman"; // "name":"spiderman"
|
||||
j["gear"]["suits"] = "2099"; // "gear":{"suits":"2099"}
|
||||
j["jobs"] = {"superhero"}; // "jobs":["superhero"]
|
||||
|
||||
vector<int> v = {1,2,3}; // vector容器
|
||||
j["numbers"] = v; // "numbers":[1,2,3]
|
||||
|
||||
map<string, int> m = // map容器
|
||||
{{"one",1}, {"two", 2}}; // 初始化列表
|
||||
j["kv"] = m; // "kv":{"one":1,"two":2}
|
||||
|
||||
```
|
||||
|
||||
添加完之后,用成员函数dump()就可以序列化,得到它的JSON文本形式。默认的格式是紧凑输出,没有缩进,如果想要更容易阅读的话,可以加上指示缩进的参数:
|
||||
|
||||
```
|
||||
cout << j.dump() << endl; // 序列化,无缩进
|
||||
cout << j.dump(2) << endl; // 序列化,有缩进,2个空格
|
||||
|
||||
```
|
||||
|
||||
json_t的反序列化功能同样也很简单,只要调用静态成员函数parse()就行,直接得到JSON对象,而且可以用auto自动推导类型:
|
||||
|
||||
```
|
||||
string str = R"({ // JSON文本,原始字符串
|
||||
"name": "peter",
|
||||
"age" : 23,
|
||||
"married" : true
|
||||
})";
|
||||
|
||||
auto j = json_t::parse(str); // 从字符串反序列化
|
||||
assert(j["age"] == 23); // 验证序列化是否正确
|
||||
assert(j["name"] == "peter");
|
||||
|
||||
```
|
||||
|
||||
json_t使用异常来处理解析时可能发生的错误,如果你不能保证JSON数据的完整性,就要使用try-catch来保护代码,防止错误数据导致程序崩溃:
|
||||
|
||||
```
|
||||
auto txt = "bad:data"s; // 不是正确的JSON数据
|
||||
|
||||
try // try保护代码
|
||||
{
|
||||
auto j = json_t::parse(txt);// 从字符串反序列化
|
||||
}
|
||||
catch(std::exception& e) // 捕获异常
|
||||
{
|
||||
cout << e.what() << 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<int> v = {1,2,3,4,5}; // vector容器
|
||||
|
||||
msgpack::sbuffer sbuf; // 输出缓冲区
|
||||
msgpack::pack(sbuf, v); // 序列化
|
||||
|
||||
```
|
||||
|
||||
从代码里你可以看到,它的用法不像JSON那么简单直观,**必须同时传递序列化的输出目标和被序列化的对象**。
|
||||
|
||||
输出目标sbuffer是个简单的缓冲区,你可以把它理解成是对字符串数组的封装,和`vector<char>`很像,也可以用data()和size()方法获取内部的数据和长度。
|
||||
|
||||
```
|
||||
cout << sbuf.size() << 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<int> v2; // vector容器
|
||||
obj.convert(v2); // 转换反序列化的数据
|
||||
|
||||
assert(std::equal( // 算法比较两个容器
|
||||
begin(v), end(v), begin(v2)));
|
||||
|
||||
```
|
||||
|
||||
因为MessagePack不能直接打包复杂数据,所以用起来就比JSON麻烦一些,你必须自己把数据逐个序列化,连在一起才行。
|
||||
|
||||
好在MessagePack又提供了一个packer类,可以实现串联的序列化操作,简化代码:
|
||||
|
||||
```
|
||||
msgpack::sbuffer sbuf; // 输出缓冲区
|
||||
msgpack::packer<decltype(sbuf)> packer(sbuf); // 专门的序列化对象
|
||||
|
||||
packer.pack(10).pack("monado"s) // 连续序列化多个数据
|
||||
.pack(vector<int>{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<string> tags;
|
||||
public:
|
||||
MSGPACK_DEFINE(id, title, tags); // 实现序列化功能的宏
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
它可以直接用于pack()和unpack(),基本上和JSON差不多了:
|
||||
|
||||
```
|
||||
Book book1 = {1, "1984", {"a","b"}}; // 自定义类
|
||||
|
||||
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 << book2.title << endl;
|
||||
|
||||
```
|
||||
|
||||
使用MessagePack的时候,你也要注意数据不完整的问题,必须要用try-catch来保护代码,捕获异常:
|
||||
|
||||
```
|
||||
auto txt = ""s; // 空数据
|
||||
try // try保护代码
|
||||
{
|
||||
auto handle = msgpack::unpack( // 反序列化
|
||||
txt.data(), txt.size());
|
||||
}
|
||||
catch(std::exception& e) // 捕获异常
|
||||
{
|
||||
cout << e.what() << 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)**,必须要先写一个IDL(Interface Description Language)文件,在里面定义好数据结构,只有预先定义了的数据结构,才能被序列化和反序列化。
|
||||
|
||||
这个特点既有好处也有坏处:一方面,接口就是清晰明确的规范文档,沟通交流简单无歧义;而另一方面,就是缺乏灵活性,改接口会导致一连串的操作,有点繁琐。
|
||||
|
||||
下面是一个简单的PB定义:
|
||||
|
||||
```
|
||||
syntax = "proto2"; // 使用第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("sony");
|
||||
v.set_valid(true);
|
||||
|
||||
assert(v.IsInitialized()); // required等字段都设置了,数据完整
|
||||
assert(v.has_id() && v.id() == 1);
|
||||
assert(v.has_name() && v.name() == "sony");
|
||||
assert(v.has_valid() && v.valid());
|
||||
|
||||
cout << v.DebugString() << endl; // 输出调试字符串
|
||||
|
||||
string enc;
|
||||
v.SerializeToString(&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="">
|
||||
325
极客时间专栏/geek/罗剑锋的C++实战笔记/技能进阶/16 | 网络通信:我不想写原生Socket.md
Normal file
325
极客时间专栏/geek/罗剑锋的C++实战笔记/技能进阶/16 | 网络通信:我不想写原生Socket.md
Normal 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 <curl/curl.h> // 包含头文件
|
||||
|
||||
auto curl = curl_easy_init(); // 创建CURL句柄
|
||||
assert(curl);
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, "http://nginx.org"); // 设置请求URI
|
||||
|
||||
auto res = curl_easy_perform(curl); // 发送数据
|
||||
if (res != CURLE_OK) { // 检查是否执行成功
|
||||
cout << curl_easy_strerror(res) << 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(&write_callback)) // decltype获取函数指针类型,显式转换
|
||||
[](char *ptr, size_t size, size_t nmemb, void *userdata)// lambda
|
||||
{
|
||||
cout << "size = " << size * nmemb << 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 && make install
|
||||
|
||||
```
|
||||
|
||||
和libcurl相比,cpr用起来真的是太轻松了,不需要考虑什么初始化、设置参数、清理等杂事,一句话就能发送HTTP请求:
|
||||
|
||||
```
|
||||
#include <cpr/cpr.h> // 包含头文件
|
||||
|
||||
auto res = cpr::Get( // GET请求
|
||||
cpr::Url{"http://openresty.org"} // 传递URL
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
你也不用写回调函数,HTTP响应就是函数的返回值,用成员变量url、header、status_code、text就能够得到报文的各个组成部分:
|
||||
|
||||
```
|
||||
cout << res.elapsed << endl; // 请求耗费的时间
|
||||
|
||||
cout << res.url << endl; // 请求的URL
|
||||
cout << res.status_code << endl; // 响应的状态码
|
||||
cout << res.text.length() << endl; // 响应的body数据
|
||||
|
||||
for(auto& x : res.header) { // 响应的头字段
|
||||
cout << x.first << "=>" // 类似map的结构
|
||||
<< x.second << 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 = "http://openresty.org"s; // 访问的URL
|
||||
|
||||
auto res1 = cpr::Head( // 发送HEAD请求
|
||||
cpr::Url{url} // 传递URL
|
||||
);
|
||||
|
||||
auto res2 = cpr::Get( // 发送GET请求
|
||||
cpr::Url{url}, // 传递URL
|
||||
cpr::Parameters{ // 传递URL参数
|
||||
{"a", "1"}, {"b", "2"}}
|
||||
);
|
||||
|
||||
auto res3 = cpr::Post( // 发送POST请求
|
||||
cpr::Url{url}, // 传递URL
|
||||
cpr::Header{ // 定制请求头字段
|
||||
{"x", "xxx"},{"expect",""}},
|
||||
cpr::Body{"post data"}, // 传递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{"http://openresty.org"}
|
||||
);
|
||||
|
||||
auto res = f.get(); // 等待响应结果
|
||||
cout << res.elapsed << 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 = [&](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 = "ipc:///dev/shm/zmq.sock"s; // 通信地址
|
||||
|
||||
auto receiver = [=]() // lambda表达式接收数据
|
||||
{
|
||||
auto sock = make_sock(ZMQ_PULL); // 创建ZMQ套接字,拉数据
|
||||
|
||||
sock.bind(addr); // 绑定套接字
|
||||
assert(sock.connected());
|
||||
|
||||
zmq::message_t msg;
|
||||
sock.recv(&msg); // 接收消息
|
||||
|
||||
string s = {msg.data<char>(), msg.size()};
|
||||
cout << s << endl;
|
||||
};
|
||||
|
||||
auto sender = [=]() // lambda表达式发送数据
|
||||
{
|
||||
auto sock = make_sock(ZMQ_PUSH); // 创建ZMQ套接字,推数据
|
||||
|
||||
sock.connect(addr); // 连接到对端
|
||||
assert(sock.connected());
|
||||
|
||||
string s = "hello zmq";
|
||||
sock.send(s.data(), s.size()); // 发送消息
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
这段代码实现了两个最基本的客户端和服务器,看起来好像没什么特别的。但你应该注意到,使用ZMQ完全不需要考虑底层的TCP/IP通信细节,它会保证消息异步、安全、完整地到达服务器,让你关注网络通信之上更有价值的业务逻辑。
|
||||
|
||||
ZMQ的用法就是这么简单,但想要进一步发掘它的潜力,处理大流量的数据还是要去看[它的文档](http://wiki.zeromq.org/),选择合适的工作模式,再仔细调节各种参数。
|
||||
|
||||
接下来,我再给你分享两个实际工作中会比较有用的细节吧。
|
||||
|
||||
一个是**ZMQ环境的线程数**。它的默认值是1,太小了,适当增大一些就可以提高ZMQ的并发处理能力。我一般用的是4~6,具体设置为多少最好还是通过性能测试来验证下。
|
||||
|
||||
另一个是**收发消息时的本地缓存数量**,ZMQ的术语叫High Water Mark。如果收发的数据过多,数量超过HWM,ZMQ要么阻塞,要么丢弃消息。
|
||||
|
||||
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="">
|
||||
367
极客时间专栏/geek/罗剑锋的C++实战笔记/技能进阶/17 | 脚本语言:搭建高性能的混合系统.md
Normal file
367
极客时间专栏/geek/罗剑锋的C++实战笔记/技能进阶/17 | 脚本语言:搭建高性能的混合系统.md
Normal 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 <pybind11/pybind11.h> // pybind11的头文件
|
||||
|
||||
PYBIND11_MODULE(pydemo, m) // 定义Python模块pydemo
|
||||
{
|
||||
m.doc() = "pybind11 demo doc"; // 模块的说明文档
|
||||
} // 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
|
||||
>>> import pydemo
|
||||
>>> help(pydemo)
|
||||
|
||||
```
|
||||
|
||||
刚才的代码非常简单,只是个空模块,里面什么都没有,现在,我们来看看怎么把C++的函数导入Python。
|
||||
|
||||
你需要用的是**def()函数**,传递一个Python函数名和C++的函数、函数对象或者是lambda表达式,形式上和Python的函数也差不多:
|
||||
|
||||
```
|
||||
namespace py = pybind11; // 名字空间别名,简化代码
|
||||
|
||||
PYBIND11_MODULE(pydemo, m) // 定义Python模块pydemo
|
||||
{
|
||||
m.def("info", // 定义Python函数
|
||||
[]() // 定义一个lambda表达式
|
||||
{
|
||||
py::print("c++ version =", __cplusplus); // pybind11自己的打印函数
|
||||
py::print("gcc version =", __VERSION__);
|
||||
py::print("libstdc++ =", __GLIBCXX__);
|
||||
}
|
||||
);
|
||||
|
||||
m.def("add", // 定义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 <pybind11/stl.h> // 转换标准容器必须的头文件
|
||||
|
||||
PYBIND11_MODULE(pydemo, m) // 定义Python模块pydemo
|
||||
{
|
||||
m.def("use_str", // 定义Python函数
|
||||
[](const string& str) // 入参是string
|
||||
{
|
||||
py::print(str);
|
||||
return str + "!!"; // 返回string
|
||||
}
|
||||
);
|
||||
|
||||
m.def("use_tuple", // 定义Python函数
|
||||
[](tuple<int, int, string> x) // 入参是tuple
|
||||
{
|
||||
get<0>(x)++;
|
||||
get<1>(x)++;
|
||||
get<2>(x)+= "??";
|
||||
return x; // 返回元组
|
||||
}
|
||||
);
|
||||
|
||||
m.def("use_list", // 定义Python函数
|
||||
[](const vector<int>& v) // 入参是vector
|
||||
{
|
||||
auto vv = v;
|
||||
py::print("input :", 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_<Point>(m, "Point") // 定义Python类
|
||||
.def(py::init()) // 导出构造函数
|
||||
.def(py::init<int>()) // 导出构造函数
|
||||
.def("get", &Point::get) // 导出成员函数
|
||||
.def("set", &Point::set) // 导出成员函数
|
||||
;
|
||||
|
||||
```
|
||||
|
||||
对于一般的成员函数来说,定义的方式和普通函数一样,只是你必须加上取地址操作符“&”,把它写成函数指针的形式。而构造函数则比较特殊,必须调用init()函数来表示,如果有参数,还需要在init()函数的模板参数列表里写清楚。
|
||||
|
||||
pybind11的功能非常丰富,我们不可能一下子学完全部的功能,刚才说的这些只是最基本,也是非常实用的功能。除了这些,它还支持异常、枚举、智能指针等很多C++特性,你可以再参考一下它的[文档](https://github.com/pybind/pybind11),学习一下具体的方法,挖掘出它的更多价值。
|
||||
|
||||
如果你在工作中重度使用Python,那么pybind11绝对是你的得力助手,它能够让C++紧密地整合进Python应用里,让Python跑得更快、更顺畅,建议你有机会就尽量多用。
|
||||
|
||||
## Lua
|
||||
|
||||
接下来我要说的第二个脚本语言是小巧高效的Lua,号称是“最快的脚本语言”。
|
||||
|
||||
你可能对Lua不太了解,但你一定听说过《魔兽世界》《愤怒的小鸟》吧,它们就在内部大量使用了Lua来编写逻辑。在游戏开发领域,Lua可以说是一种通用的工作语言。
|
||||
|
||||
Lua与其他语言最大的不同点在于它的设计目标:不追求“大而全”,而是“小而美”。Lua自身只有很小的语言核心,能做的事情很少。但正是因为它小,才能够很容易地嵌入到其他语言里,为“宿主”添加脚本编程的能力,让“宿主”更容易扩展和定制。
|
||||
|
||||
标准的Lua(PUC-Rio Lua)使用解释器运行,速度虽然很快,但和C/C++比起来还是有差距的。所以,你还可以选择另一个兼容的项目:LuaJIT([https://luajit.org/](https://luajit.org/))。它使用了JIT(Just 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 && 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 "C" { // 使用纯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 "ffi" -- 加载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("./liblua_shared.so") -- 加载动态库
|
||||
|
||||
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<lua_State> 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, "package"); // 获取内置的package模块
|
||||
|
||||
string path = package["path"]; // 默认的lua脚本搜索路径
|
||||
string cpath = package["cpath"]; // 默认的动态库搜索路径
|
||||
|
||||
```
|
||||
|
||||
你还可以调用**luaL_dostring()和luaL_dofile()**这两个函数,直接执行Lua代码片段或者外部的脚本文件。注意,luaL_dofile()每次调用都会从磁盘载入文件,所以效率较低。如果是频繁调用,最好把代码读进内存,存成一个字符串,再用luaL_dostring()运行:
|
||||
|
||||
```
|
||||
luaL_dostring(L, "print('hello lua')"); // 执行Lua代码片段
|
||||
luaL_dofile(L, "./embedded.lua"); // 执行外部的脚本文件
|
||||
|
||||
```
|
||||
|
||||
在C++里嵌入Lua,还有另外一种方式:**提前在脚本里写好一些函数,加载后在C++里逐个调用**,这种方式比执行整个脚本更灵活。
|
||||
|
||||
具体的做法也很简单,先用luaL_dostring()或者luaL_dofile()加载脚本,然后调用getGlobal()从全局表里获得封装的LuaRef对象,就可以像普通函数一样执行了。由于Lua是动态语言,变量不需要显式声明类型,所以写起来就像是C++的泛型函数,但却更简单:
|
||||
|
||||
```
|
||||
string chunk = R"( -- Lua代码片段
|
||||
function say(s) -- Lua函数1
|
||||
print(s)
|
||||
end
|
||||
function add(a, b) -- Lua函数2
|
||||
return a + b
|
||||
end
|
||||
)";
|
||||
|
||||
luaL_dostring(L, chunk.c_str()); // 执行Lua代码片段
|
||||
|
||||
auto f1 = getGlobal(L, "say"); // 获得Lua函数
|
||||
f1("say something"); // 执行Lua函数
|
||||
|
||||
auto f2 = getGlobal(L, "add"); // 获得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="">
|
||||
239
极客时间专栏/geek/罗剑锋的C++实战笔记/技能进阶/18 | 性能分析:找出程序的瓶颈.md
Normal file
239
极客时间专栏/geek/罗剑锋的C++实战笔记/技能进阶/18 | 性能分析:找出程序的瓶颈.md
Normal 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”,然后用“<>”手动选择排序的列,这样查看起来更自由。
|
||||
|
||||
我曾经做过一个“魔改”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& filename) // 传入性能分析的数据文件名
|
||||
{
|
||||
ProfilerStart(filename.c_str()); // 启动性能分析
|
||||
ProfilerRegisterThread(); // 对线程做性能分析
|
||||
|
||||
return std::shared_ptr<void>( // 返回智能指针
|
||||
nullptr, // 空指针,只用来占位
|
||||
[](void*){ // 删除函数执行停止动作
|
||||
ProfilerStop(); // 停止性能分析
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
```
|
||||
|
||||
下面我写一小段代码,测试正则表达式处理文本的性能:
|
||||
|
||||
```
|
||||
auto cp = make_cpu_profiler("case1.perf"); // 启动性能分析
|
||||
auto str = "neir:automata"s;
|
||||
|
||||
for(int i = 0; i < 1000; i++) { // 循环一千次
|
||||
auto reg = make_regex(R"(^(\w+)\:(\w+)$)");// 正则表达式对象
|
||||
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 > 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 > case1.svg
|
||||
|
||||
pprof --collapsed ./a.out case1.perf > case1.cbt
|
||||
flamegraph.pl case1.cbt > flame.svg
|
||||
flamegraph.pl --invert --color aqua case1.cbt > 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"(^(\w+)\:(\w+)$)"); // 正则表达式对象
|
||||
auto what = make_match();
|
||||
|
||||
for(int i = 0; i < 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="">
|
||||
Reference in New Issue
Block a user