前言

前段时间有人在 opentelemetry-cpp 提出了api组件在动态库中单例无法工作的 issue ,( https://github.com/open-telemetry/opentelemetry-cpp/issues/1520 ) 。

opentelemetry 是可观测性领域的开源项目,目标是统一链路跟踪、数据指标和日志的服务、上报、协议和接口规范,目前属于 CNCF基金会 孵化项目。而 opentelemetry-cpp 则是 opentelemetry 中对标准规范SDK的C++实现。

其本质原因是 opentelemetry-cpp 规范要求api组件必须是header-only的( https://github.com/open-telemetry/opentelemetry-cpp/blob/main/docs/requirements.md#requirements )。

opentelemetry-cpp 一直以来仅仅提供了跨平台的静态库支持,对动态库并没有找到一个特别理想的方式。这里记录一下当时整个讨论的要点记录。

背景

三大主流平台(macOS,Linux和Windows )中用的ABI规范都不太一样。其中macOS的Mach-O和Linux下的ELF差别不是很大,但是它们Windows下PE ABI的差异很大。 对动态库而言,实际上所有写在 header 里的接口和全局变量都有一份自己的实例。而关键性的差异主要是 ELF和Mach-O 对于 -fvisibility=default 的变量都是去GOT查找,而如果GOT找不到会走到第一次调用的模块的初始化流程,然后初始化完以后会把这个地址写入GOT,GOT是所有模块共享的,函数符号表也是共享的。

另外,虽然 -fvisibility=default 是可见性设置的默认值,但是有些下游库为了版本兼容性和符号隔离,会在编译选项中加入 -fvisibility=hidden 来使得符号是默认隐藏的。这种情况又不太一样,各个模块会直接使用本地的函数,不再从GOT中查找。导致各个模块最终使用的同名变量和函数地址不一样。

而在PE中,dll调用函数,除非标记为 __declspec(dllimport),总是从本模块的符号表总查找。而标记为 __declspec(dllimport) 的函数不会生成本地实例,所以会要求必须要有某个模块申明了 __declspec(dllexport) 来导出实际的实例。每个dll和exe都有自己独立符号表和堆管理。

介于以上原因,大多数 C/C++ 库的解决方式是在声明接口时加一个宏 XXX_API ,然后不同的场景通过宏来切换到不同的编译分支,比如:

// ================ import/export: for compilers ================
#if defined(__GNUC__) && !defined(__ibmxl__)
//  GNU C++/Clang
//
// Dynamic shared object (DSO) and dynamic-link library (DLL) support
//
#  if __GNUC__ >= 4
#    if defined(_WIN32) || defined(__WIN32__) || defined(WIN32) || defined(__CYGWIN__)
// All Win32 development environments, including 64-bit Windows and MinGW, define
// _WIN32 or one of its variant spellings. Note that Cygwin is a POSIX environment,
// so does not define _WIN32 or its variants.
#      ifndef OPENTELEMETRY_SYMBOL_EXPORT
#        define OPENTELEMETRY_SYMBOL_EXPORT __attribute__((__dllexport__))
#      endif
#      ifndef OPENTELEMETRY_SYMBOL_IMPORT
#        define OPENTELEMETRY_SYMBOL_IMPORT __attribute__((__dllimport__))
#      endif
#    else
#      ifndef OPENTELEMETRY_SYMBOL_EXPORT
#        define OPENTELEMETRY_SYMBOL_EXPORT __attribute__((visibility("default")))
#      endif
#      ifndef OPENTELEMETRY_SYMBOL_IMPORT
#        define OPENTELEMETRY_SYMBOL_IMPORT __attribute__((visibility("default")))
#      endif
#    endif
#  else
// config/platform/win32.hpp will define OPENTELEMETRY_SYMBOL_EXPORT, etc., unless already defined
#    ifndef OPENTELEMETRY_SYMBOL_EXPORT
#      define OPENTELEMETRY_SYMBOL_EXPORT
#    endif
#    ifndef OPENTELEMETRY_SYMBOL_IMPORT
#      define OPENTELEMETRY_SYMBOL_IMPORT
#    endif
#  endif
#elif defined(_MSC_VER)
//  Microsoft Visual C++
//
//  Must remain the last #elif since some other vendors (Metrowerks, for
//  example) also #define _MSC_VER
#else
#endif
// ---------------- import/export: for compilers ----------------

// ================ import/export: for platform ================
//  Default defines for OPENTELEMETRY_SYMBOL_EXPORT and OPENTELEMETRY_SYMBOL_IMPORT
//  If a compiler doesn't support __declspec(dllexport)/__declspec(dllimport),
//  its file must define OPENTELEMETRY_SYMBOL_EXPORT and OPENTELEMETRY_SYMBOL_IMPORT
#if !defined(OPENTELEMETRY_SYMBOL_EXPORT) && (defined(_WIN32) || defined(__WIN32__) || defined(WIN32) || defined(__CYGWIN__))

#  ifndef OPENTELEMETRY_SYMBOL_EXPORT
#    define OPENTELEMETRY_SYMBOL_EXPORT __declspec(dllexport)
#  endif
#  ifndef OPENTELEMETRY_SYMBOL_IMPORT
#    define OPENTELEMETRY_SYMBOL_IMPORT __declspec(dllimport)
#  endif
#endif
// ---------------- import/export: for platform ----------------

#ifndef OPENTELEMETRY_SYMBOL_EXPORT
#  define OPENTELEMETRY_SYMBOL_EXPORT
#endif
#ifndef OPENTELEMETRY_SYMBOL_IMPORT
#  define OPENTELEMETRY_SYMBOL_IMPORT
#endif
// ---------------- import/export ----------------

// private definition for both static and dynamic library.
#define OPENTELEMETRY_API_API_NATIVE

// public definition for dynamic library only.
#define OPENTELEMETRY_API_API_DLL

// And then declare these.
#if defined(OPENTELEMETRY_API_API_NATIVE) && OPENTELEMETRY_API_API_NATIVE
#  if defined(OPENTELEMETRY_API_API_DLL) && OPENTELEMETRY_API_API_DLL
#    define OPENTELEMETRY_API_API OPENTELEMETRY_SYMBOL_EXPORT
#  else
#    define OPENTELEMETRY_API_API
#  endif
#else
#  if defined(OPENTELEMETRY_API_API_DLL) && OPENTELEMETRY_API_API_DLL
#    define OPENTELEMETRY_API_API OPENTELEMETRY_SYMBOL_IMPORT
#  else
#    define OPENTELEMETRY_API_API
#  endif
#endif
#define OPENTELEMETRY_API_API_HEAD_ONLY OPENTELEMETRY_SYMBOL_VISIBLE

详见: https://github.com/open-telemetry/opentelemetry-cpp/issues/1105

在Linux和macOS中,符号设置为 __attribute__((visibility("default"))) 之后,对某个名字的函数调用总会找到同一个函数地址(除非 dlopen+dlsym 然后显式按地址调用)。 在Windows下,有且只能有一个模块的代码实现把 OPENTELEMETRY_SYMBOL_EXPORT 声明为 __declspec(dllexport) ,其他模块声明为 __declspec(import) 。 只有一个模块有实现,其他的模块都是导入这个模块的实现,否则链接时会报重定义。所以这个申明必须位于 cpp 文件中,不能位于头文件中。这就和前面提到的 opentelemetry-cpp 规范要求 api组件必须是header-only的 相冲突。而但凡写在头文件里。就会导致不同的模块对 单例 对象的引用,使用不同的变量地址,从而失去了 单例 的意义。

几个编译环境的example

在issue讨论中,首先对于Linux/macOS,可以显式把涉及单例的接口设置为 __attribute__((visibility("default"), weak)) ,这样不受全局选项的影响。而对于Windows,有人提到了在MSVC中可以用 __declspec(selectany) 代替 __attribute__((visibility("default"), weak)) 。看 __declspec( selectany ) 的文档,似乎确实是一个解决方案。同时在Windows下的 GCC/Clang 中,也有对应的选项 __attribute__((selectany)) (注意 selectany 只能用于变量,不能用于函数)。所以我写了几个Windows下的example用于测试可行性。

Case 1: Windows+GCC+visibility+static local变量

文件: test_dll.h

#pragma once

#include <iostream>

struct foo {
  __attribute__((visibility("default"))) static void print_addr() {
    __attribute__((visibility("default"))) static foo inst;
    inst.print();
    std::cout<< "print_addr address: "<< foo::print_addr<< std::endl;
  }

  void print() {
    std::cout<< "Instance address: "<< this<< std::endl;
  }
};

文件: test_dll.cpp

#include "test_dll.h"

#include <iostream>

__attribute__((dllexport)) void call_print() {
  foo::print_addr();
}

文件: test_main.cpp

#include "test_dll.h"

__attribute__ ((dllimport)) void call_print();

int main() {
  foo::print_addr();
  call_print();
  return 0;
}

编译指令和执行结果:

$ g++ test_dll.cpp -shared -o test_dll.dll
In file included from test_dll.cpp:1:
test_dll.h: In static member function 'static void foo::print_addr()':
test_dll.h:7:56: warning: 'visibility' attribute ignored [-Wattributes]
    7 |     __attribute__((visibility("default"))) static foo inst;
      |                                                       ^~~~

$ g++ test_main.cpp -o test_dll.exe -L. -ltest_dll
In file included from test_main.cpp:1:
test_dll.h: In static member function 'static void foo::print_addr()':
test_dll.h:7:56: warning: 'visibility' attribute ignored [-Wattributes]
    7 |     __attribute__((visibility("default"))) static foo inst;
      |                                                       ^~~~


$ ./test_dll.exe
Instance address: 0x22e66cb1410
print_addr address: 1
Instance address: 0x22e66b834a0
print_addr address: 1

$ cat /etc/os-release
NAME=MSYS2
ID=msys2
PRETTY_NAME="MSYS2"
ID_LIKE="cygwin arch"
HOME_URL="https://www.msys2.org"
BUG_REPORT_URL="https://github.com/msys2/MSYS2-packages/issues"

$ gcc -v
Using built-in specs.
COLLECT_GCC=C:\msys64\mingw64\bin\gcc.exe
COLLECT_LTO_WRAPPER=C:/msys64/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/12.1.0/lto-wrapper.exe
Target: x86_64-w64-mingw32
Configured with: ../gcc-12.1.0/configure --prefix=/mingw64 --with-local-prefix=/mingw64/local --build=x86_64-w64-mingw32 --host=x86_64-w64-mingw32 --target=x86_64-w64-mingw32 --with-native-system-header-dir=/mingw64/include --libexecdir=/mingw64/lib --enable-bootstrap --enable-checking=release --with-arch=x86-64 --with-tune=generic --enable-languages=c,lto,c++,fortran,ada,objc,obj-c++,jit --enable-shared --enable-static --enable-libatomic --enable-threads=posix --enable-graphite --enable-fully-dynamic-string --enable-libstdcxx-filesystem-ts --enable-libstdcxx-time --disable-libstdcxx-pch --enable-lto --enable-libgomp --disable-multilib --disable-rpath --disable-win32-registry --disable-nls --disable-werror --disable-symvers --with-libiconv --with-system-zlib --with-gmp=/mingw64 --with-mpfr=/mingw64 --with-mpc=/mingw64 --with-isl=/mingw64 --with-pkgversion='Rev2, Built by MSYS2 project' --with-bugurl=https://github.com/msys2/MINGW-packages/issues --with-gnu-as --with-gnu-ld --disable-libstdcxx-debug --with-boot-ldflags=-static-libstdc++ --with-stage1-ldflags=-static-libstdc++
Thread model: posix
Supported LTO compression algorithms: zlib zstd
gcc version 12.1.0 (Rev2, Built by MSYS2 project)

显然,两个模块内地址不一致。

Case 2: Windows+GCC+visibility+全局变量

文件: test_dll.h

#pragma once

#include <iostream>

struct foo {
  __attribute__((visibility("default"))) static void print_addr() {
    inst.print();
    std::cout<< "print_addr address: "<< foo::print_addr<< std::endl;
  }

  void print() {
    std::cout<< "Instance address: "<< this<< std::endl;
  }

  __attribute__((weak, visibility("default"))) static foo inst;
};


__attribute__((weak, visibility("default"))) foo foo::inst;

其他文件同 Case 1

编译指令和执行结果:

$ ./test_dll.exe
Instance address: 0x7ff7750f7040
print_addr address: 1
Instance address: 0x7ff87b247020
print_addr address: 1

显然,两个模块内地址也不一致。

Case 3: Windows+MSVC+__declspec(selectany)

文件: test_dll.h

#include "test_dll.h"

#include <iostream>

__declspec(dllexport) void call_print() {
  foo::print_addr();
}

文件: test_dll.cpp

#include "test_dll.h"

#include <iostream>

__declspec(dllexport) void call_print() {
  foo::print_addr();
}

文件: test_main.cpp

#include "test_dll.h"

__declspec(dllimport) void call_print();

int main() {
    foo::print_addr();
    call_print();
    return 0;
}

编译指令和执行结果(直接链接 .dll):

cl /nologo /O2 /MD /Zi /Z7 /LD test_dll.cpp
cl /nologo /O2 /MD /Zi /Z7 test_main.cpp /link test_dll

d:\workspace\test\vcconsole\testdll>test_main.exe
Instance address: 00007FF6551F9150
print_addr address: 00007FF6551F1140
Instance address: 00007FF6551F9150
print_addr address: 00007FF6551F1140

这种方法和我们预计的结果一致,单例的地址唯一了。但是对于所有的dll,MSVC还是生成一个用于 .lib 文件也是用于链接的,像cmake这类构建工具,只会链接这个.lib文件,而不是直接链接 .dll 。

我们再来试一下链接.dll对应的.lib:

cl /nologo /O2 /MD /Zi /Z7 /LD test_dll.cpp
cl /nologo /O2 /MD /Zi /Z7 test_main.cpp /link test_dll.lib

d:\workspace\test\vcconsole\testdll>test_main.exe
Instance address: 00E08130
print_addr address: 00E0114A
Instance address: 796A8120
print_addr address: 796A1122

这种方式地址又不一样了。

Case 4: Windows+GCC+__attribute__((selectany))

文件: test_dll.h

#pragma once

#include <iostream>

struct foo {
  __attribute__((visibility("default"))) static void print_addr() {
    inst.print();
    std::cout<< "print_addr address: "<< foo::print_addr<< std::endl;
  }

  void print() {
    std::cout<< "Instance address: "<< this<< std::endl;
  }

  __attribute__((visibility("default"), weak)) static foo inst;
};

__attribute__((visibility("default"), weak, selectany)) foo foo::inst;

其他文件同 Case 1

编译指令和执行结果:

$ ./test_dll.exe
Instance address: 0x7ff735ac30b0
print_addr address: 1
Instance address: 0x7ff8d19c3070
print_addr address: 1

显然,结果仍然是地址不一致。

ELF的特例(global变量和static local变量的差异)

这里顺便提及一下Linux下全局变量和函数内static变量的差异。macOS我没深入研究过不过估计结论应该类似。

首先global变量是模块加载时自动初始化,初始化顺序不定。而函数内static变量是在第一次访问时初始化。 虽然C++ 11规定函数内static变量的初始化必须是线程安全的,但是GCC和Clang都是通过一个atomic操作去判定是否初始化的。并且 acquire 操作, 复制操作, release 操作是三条指令。 仍然有可能多线程同时进入。

而global变量首先的问题是初始化顺序不确定,导致如果多个组件互相依赖的话初始化顺序也是不定的。

另一个更大的问题是,对全局变量的初始化会直接写进模块的初始化函数,这会导致如果多个模块引用同名全局变量,虽然符号地址走GOT是统一了,但是构造和析构函数会执行多次。 这会带来严重问题,细节可以参考 https://godbolt.org/z/7aTTh939c 这个输出的汇编。

这里也有一个和这个有关的 issue https://github.com/open-telemetry/opentelemetry-cpp/issues/1603 。 问题根源是 gRPC 里有这样的全局变量,当以静态库编译 gRPC ,动态库编译 opentelemetry-cpp 时, gRPC 的相关全局变量被多次初始化,导致部分数据被覆盖。 解决方法就是增加了一个 opentelemetry-cpp::otlp_grpc_client 模块,有且仅有这个模块链接 gRPC 。 相关PR是 https://github.com/open-telemetry/opentelemetry-cpp/pull/1606

最后

通过上面的例子,Linux和macOS下我们可以通过一定的约定和规范避免问题,但是Windows下仍然没有一个完美的解决方案。

当然,有的同学说,我只用Linux/macOS,不用考虑Windows。然而我们做基础性功能库的时候不能定死用户的使用场景和限制使用平台,所以对接口的规范和约定设计会更加偏向保守。 当前版本的 opentelemetry-cpp 中,已经把单例中GCC和Clang编译时符号设置为 __attribute__((visibility("default"), weak)) 。 这样头文件里的函数和变量可见性不受全局编译选项的影响,总是共享。于此同时我们必须注意ABI兼容性,因为无法在通过 -fvisibility=hidden 个隔离多版本。 在 opentelemetry-cpp 中还通过 inline namespace 机制来隔离多版本的ABI兼容性(仅限于stable接口)。

而对于Windows,目前还是仅支持编译成静态库,但是可以链接到动态库中且多个模块间互不影响。

欢迎有兴趣的小伙伴们交流。