前言
opentelemetry-cpp 在标准上报协议OTLP里是支持使用 gRPC 作为传输协议的。但是,当 gRPC 被作为静态库同时链接进多个动态库时,在一些平台上会有一些问题。这是 gRPC 本身的一些实现方式导致的。 一直拖到今天才来比较完整得写这个问题的具体成因和解决方案,实际上也有一些其他库有相似实现的也会有相同的问题,所以分享出来看看有没有其他同学也可能碰到可以参考一下。
问题分析
首先是第一个静态库和动态库混用的问题,这个问题早在去年就有人提了issue(“example_otlp_grpc_log” sample not running. libopentelemetry_exporter_otlp_grpc_log.so and libopentelemetry_exporter_otlp_grpc.so library symbols are mixing/overlapping when used together. #1603)。这个问题的具体原因是因为 gRPC 初始化内部一些数据结构的时候使用了全局变量,并且通过再全部变量构造函数中完成一些全局只需要执行一次的注册类函数。这种情况如果我们把 gRPC 编译成静态库,并链接进多个动态库里,那么每个动态库里都有一份 gRPC 的全局变量和函数符号。在Windows中,由于每个dll有自己独立的符号表和堆管理,如果多个模块间没有互相访问,那么这种重复是没有关系的,因为每个模块访问的都是自己的那一份全局变量(当然如果这个全局变量想表达单例的话,那么他可能不是一个真正单例,具体可以参考我另一篇文章 《关于opentelemetry-cpp社区对于C++ Head Only组件单例和符号可见性的讨论小记》 )。而在ELF ABI(Linux)下,情况变得有点不一样了,因为在ELF ABI下整个堆和符号表是整个可执行程序共享的,ld.so 保证了当多个动态库包含相同的符号(通常是链接了相同的库)的时候,默认选中最早链接进的那一个。这种情况下不是说重复的符号不存在了,只是仅仅使用了其中一个,并且使用的是统一的一个。对于函数而言,我们认为所有同名符号的代码都是一样的(先不考虑多版本问题),所以选择任意一个都不影响结果。而对于全局变量,却是每个模块里面都存在单独的实体,每个都会去执行构造函数,并且由于默认选中的符号是相同的,所以导致执行构造函数的地址也是相同的,这就会导致问题。
我在 https://github.com/open-telemetry/opentelemetry-cpp/pull/1891 里也简单地重现了这个问题。比如有以下文件列表:
a.h
struct foo {
int bar;
foo();
~foo();
void print(const char*);
static foo _;
};
void print_static_global(const char*);
a.cpp
#include "a.h"
#include <iostream>
#include <thread>
#include <chrono>
#include <memory>
struct bar {
foo* ptr = nullptr;
};
foo foo::_;
static bar s_;
foo::foo(): bar(127) {
s_.ptr = this;
std::cout<< "construct "<< this<< std::endl;
}
foo::~foo() {
std::cout<< "destroy "<< this<< std::endl;
}
void foo::print(const char* prefix) {
std::cout<< prefix<< "-foo: "<< this<< ": "<< bar<< std::endl;
}
void print_static_global(const char* prefix) {
foo::_.print(prefix);
std::cout<< prefix<< "-piblic API bar: "<< s_.ptr<< std::endl;
}
std::shared_ptr<std::thread> g_t(new std::thread([]() {
std::this_thread::sleep_for(std::chrono::seconds{1});
std::cout<< "internal API bar: "<< s_.ptr<< std::endl;
}), [](std::thread* thd) {
thd->join();
delete thd;
});
b.cpp
#include "a.h"
void dll_func_b() {
print_static_global("b");
}
c.cpp
#include "a.h"
void dll_func_b();
int main() {
print_static_global("c");
dll_func_b();
return 0;
}
我们执行以下编译命令,结果如下:
[owent@VM-144-59-centos test]$ clang++ a.cpp -o libtest_a.a -c -fPIC -pthread
[owent@VM-144-59-centos test]$ clang++ b.cpp -o libtest_b.so -shared -fPIC -L$PWD -ltest_a -pthread
[owent@VM-144-59-centos test]$ clang++ c.cpp -fPIC -L$PWD -ltest_b -ltest_a '-Wl,-rpath=$ORIGIN' -pthread
[owent@VM-144-59-centos test]$ ./a.out
[owent@VM-144-59-centos test]$ ./a.out
construct 0x55af97755338
construct 0x55af97755338
c-foo: 0x55af97755338: 127
c-piblic API bar: 0x55af97755338
b-foo: 0x55af97755338: 127
b-piblic API bar: 0x55af97755338
internal API bar: (nil)
internal API bar: 0x55af97755338
destroy 0x55af97755338
destroy 0x55af97755338
[owent@VM-144-59-centos test]$
可以看出来,如果我们在这种情况下载构造和析构函数里有内存分配和释放会是非常危险的。在 gRPC 的场景里,由于某个内部数据接口的注册写在了全局变量的构造函数里,多次构造导致接口注册被后载入的模块覆盖了。而后续使用的时候用来比较的符号又是最早载入的接口,导致不匹配而不可用。
解决方案
这个问题如果是我们自己的代码的话解决方法很简单。就是不使用全局变量,可以使用静态函数返回static变量来代替。这么做有两个好处,第一是存在多份符号的时候因为总会调用第一次载入的符号接口,那么能保证访问到的总是同一份变量,并且还减少了不必要的构造和析构带来的内存或CPU开销。第二个好处是能控制这个变量的初始化时机。全局变量的初始化顺序是不定的,但是函数内static变量总是在第一次访问的时候初始化,这是有保障的。
然而我们没法大规模去修改 gRPC 的代码,所以我们采用另一种方式规避这个问题。新增一个编译目标 otlp_grpc_client
,并仅在这个编译目标里PRIVATE链接 gRPC。这样就能保证 gRPC 不被传递链接多次。这种方法无法解决其他库也链接 gRPC 而和 opentelemetry-cpp 冲突的问题,我们只是解决了当用户仅仅使用 opentelemetry-cpp 并编译成动态库,而 gRPC 使用静态库时的问题。本质上我们还是建议用户要么所有第三方库依赖都是用动态库,要么都使用静态库的。
这个问题的PR见: https://github.com/open-telemetry/opentelemetry-cpp/pull/1606 。
又引入了新问题
在修复了上面的问题以后,又引入了第二个问题。这和 gRPC 的 grpc::Status::OK
实现方式有关(最新版本又变更实现,可能这个问题被缓解了)。这个符号位于 gRPC 的库中,因为上层库没有直接使用这个符号(我们上面托管给了 otlp_grpc_client
),而对 grpc::Status::OK
的引用有出现在了 gRPC 的头文件中。这导致某些工具链下出现未定义的符号的链接错误。具体可参考 https://github.com/open-telemetry/opentelemetry-cpp/issues/1940 和 https://github.com/open-telemetry/opentelemetry-cpp/issues/1998 。
我们的解决方法也很简单,把对 gRPC 的直接调用改为托管到 otlp_grpc_client
中,让对 grpc::Status::OK
的引用生成在 otlp_grpc_client
中。因为 otlp_grpc_client
会链接 gRPC ,所以不会出现符号找不到的问题。相关的变更可以参考 https://github.com/open-telemetry/opentelemetry-cpp/pull/2005 。
总结
上面的问题本质上还是动态库和静态库混合使用的问题。由于不同操作系统的ABI和行为不一样,导致很难有大一统的方法去解决这些问题。也属于C++的历史包袱和大家会觉得“难”的地方之一吧。
目前推荐的跨平台兼容性比较好的做法是对输出呃接口使用符号导出(Windows)或声明为可见(Linux/macOS等),然后把默认可见性改成 -fvisibility=hidden
。这样能尽可能保证平台一致性,减少不必要的符号导出以降低链接器负担。但是即便这样,对于head only的C++库而言,可能会导致可见性切换而导致一些其他告警(比如macOS上的STL)。一些现代化语言(比如 Rust)是在语言层面就用类似的方式去规避这个问题了,确实心智负担会小很多。
最后,欢迎有兴趣的小伙伴们互相交流。