返回文章索引

C++ 每日一题:动态库中哪些内容共享?全局变量如何处理?

从多进程、多线程和重复 dlopen 三个层次说明动态库的代码段、数据段、全局变量与线程局部变量如何共享,并给出可维护的全局状态设计。

GitHub 原文

问:动态库中哪些内容共享,哪些不共享?全局变量应该如何处理?

假设 Linux 上有一个 libfoo.so

  1. 多个进程加载同一个动态库时,代码和数据是否共享?
  2. 同一进程的多个线程是否共享库中的全局变量?
  3. 同一进程多次调用 dlopen(),会不会产生多份全局变量?
  4. 动态库确实需要保存状态时,应该怎样设计?

先记住一句话:

动态库文件可以被多个进程共同映射,但普通全局变量属于进程地址空间;同一进程的线程共享它,不同进程各有一份。

这里最容易混淆的是两种“共享”:

  • 物理页共享:多个进程的页表可以指向同一份只读物理页,用来节省内存。
  • 程序状态共享:一个执行单元修改数据后,另一个执行单元能否看到修改。

代码页可能在物理内存中共享,不代表动态库中的可写全局变量会自动成为跨进程共享状态。

一、多个进程加载同一个动态库

以 ELF 动态库为例,加载器会按照程序头中的 PT_LOAD 段,把文件映射到每个进程的虚拟地址空间。

内容 多进程之间的典型情况 原因
.text 机器指令 只读物理页通常可以共享 文件内容相同且不可写
只读常量 未被运行时修改的页通常可以共享 只读文件映射可复用页缓存
.data 已初始化全局变量 逻辑上每个进程独立 可写映射通常采用私有、写时复制语义
.bss 零初始化全局变量 每个进程独立 属于各进程自己的可写地址空间
GOT 等重定位数据 通常需要进程自己的内容 加载地址和符号绑定可能不同
动态库内部 new 出来的对象 每个进程独立 来自各进程自己的堆
thread_local 变量 每个线程独立 属于线程局部存储 TLS

例如动态库中有:

int request_count = 0;
const char library_name[] = "foo";

进程 A 执行 ++request_count,进程 B 中的 request_count 不会跟着变化。两个进程看到的变量虚拟地址也可能因为 ASLR 而不同。

可写页在尚未修改时,底层有机会暂时复用相同物理页;第一次写入后会触发写时复制。无论底层是否暂时复用页面,从程序语义看,各进程的普通全局变量始终是独立的。

同理,fork() 后父子进程最初拥有相同内容,但普通全局变量也是写时复制,任何一方修改都不会自动同步给另一方。

二、同一进程中的多个线程

同一进程的线程共享同一个虚拟地址空间,因此它们会共享:

  • 动态库的代码和普通静态数据。
  • 全局变量、命名空间作用域变量。
  • 函数内 static 对象。
  • 动态库在堆上创建且可被共同访问的对象。
  • 文件描述符等进程级资源。

每个线程独立拥有:

  • 寄存器上下文和栈。
  • thread_local 变量。
  • 调度状态以及部分线程级系统资源。

因此,动态库中的普通可写全局变量会成为进程内所有线程的共享可变状态。只要至少一个线程写入,就必须考虑同步;否则可能产生数据竞争,而 C++ 中的数据竞争属于未定义行为。

// 错误:多个线程同时执行可能发生数据竞争。
int request_count = 0;

void record_request()
{
    ++request_count;
}

简单计数可以使用原子变量:

#include <atomic>

std::atomic<unsigned long long> request_count{0};

void record_request()
{
    request_count.fetch_add(1, std::memory_order_relaxed);
}

如果状态由多个字段组成,或者一次操作必须保持复杂不变量,应使用 std::mutex 保护完整临界区,而不是把每个字段分别改成原子变量。

三、同一进程多次 dlopen() 会有几份状态?

通常情况下,同一加载命名空间中对同一个共享对象重复调用 dlopen()

void* first = dlopen("./libfoo.so", RTLD_NOW);
void* second = dlopen("./libfoo.so", RTLD_NOW);

动态加载器会复用已经加载的对象,并增加引用计数,而不是重新映射一套 .data.bss。因此两次取得的函数通常访问同一份库内全局状态。

调用一次 dlclose() 也不一定立刻卸载动态库;只有引用计数归零,并且不存在阻止卸载的条件时,才可能运行析构并解除映射。

需要注意:

  • RTLD_LOCAL 只限制符号向后续加载对象的可见范围,不会让全局变量自动复制一份
  • 使用 GNU dlmopen() 创建新的链接器命名空间,可以加载相互隔离的库实例。
  • 将同一库复制成不同文件,或通过特殊加载方式加载,也可能得到多份实例。
  • 不要把“路径字符串是否相同”当成判断状态是否唯一的可靠规则,实际对象识别还受加载器、文件身份、依赖关系和命名空间影响。

一般工程代码应该把规则理解为:同一进程、同一链接器命名空间中的同一动态库,通常只有一份普通全局状态。

四、全局符号还可能被“抢走”

动态库中的全局变量除了共享问题,还有 ELF 符号绑定问题。

如果库导出了一个默认可见的变量:

int config_value = 10;

主程序或其他动态库又定义了同名强符号,动态链接时可能发生符号抢占,库内部对 config_value 的访问未必绑定到你以为的那一个对象。某些非 PIE、非 PIC 组合还可能涉及 copy relocation,使数据符号的行为更难推理。

因此,动态库最好不要把可写数据对象作为公共 ABI 直接导出:

// 不推荐作为公共接口
extern int config_value;

更稳妥的方式是只导出函数,让数据保留在库内部:

// counter.cpp
#include <atomic>
#include <cstdint>

namespace
{
std::atomic<std::uint64_t>& request_count()
{
    static std::atomic<std::uint64_t> value{0};
    return value;
}
}

extern "C" __attribute__((visibility("default")))
void counter_add() noexcept
{
    request_count().fetch_add(1, std::memory_order_relaxed);
}

extern "C" __attribute__((visibility("default")))
std::uint64_t counter_get() noexcept
{
    return request_count().load(std::memory_order_relaxed);
}

编译时默认隐藏符号,只显式导出公共 API:

g++ -std=c++17 -O2 -fPIC -fvisibility=hidden \
    -shared counter.cpp -o libcounter.so

这样设计有几个优点:

  1. 调用方不知道数据布局,库可以在不破坏 ABI 的情况下修改实现。
  2. 所有读写都经过函数入口,方便统一做锁、校验和统计。
  3. 内部符号不会轻易被其他模块抢占。
  4. C 接口避免把编译器相关的 C++ 名字修饰直接暴露为插件 ABI。

这份计数状态在同一进程内由线程共享,但在不同进程中仍然各有一份

五、真正需要跨进程共享怎么办?

普通全局变量不能解决跨进程共享。需要显式使用进程间通信机制,例如:

  • shm_open() 配合 mmap(..., MAP_SHARED, ...)
  • System V 共享内存。
  • 共享文件映射。
  • Unix Domain Socket、管道或网络服务。
  • 数据库、缓存服务等独立状态服务。

跨进程共享内存还必须额外解决:

  • 数据结构能否放在固定或不同的虚拟地址上,不能直接保存普通进程指针。
  • 并发访问如何同步,例如进程共享互斥量、原子操作或无锁协议。
  • 某个进程崩溃时,锁和共享数据如何恢复。
  • 数据版本、大小、初始化和清理如何协调。

不要仅仅把变量写进动态库,就期待多个进程看到同一个值。动态库只是代码与初始数据的载体,不是跨进程状态服务器。

六、动态库中全局状态的工程建议

1. 优先无状态接口

如果状态可以由调用方持有,就返回一个明确的上下文句柄:

foo_context* foo_create();
void foo_destroy(foo_context* context);
int foo_process(foo_context* context, const void* data);

这种方式比隐藏的进程级单例更容易测试,也允许同一进程创建多个互不影响的实例。

2. 必须全局唯一时,隐藏实现并提供访问函数

使用内部链接、隐藏可见性或函数内静态对象,不要直接导出可写变量。C++11 保证函数内静态对象的初始化线程安全,但对象初始化安全不等于后续读写安全。

3. 明确状态作用域

设计接口时直接说清楚状态属于哪一级:

  • 每次调用。
  • 每个对象实例。
  • 每个线程。
  • 每个进程。
  • 所有进程共同使用的外部服务或共享内存。

作用域不明确,是动态库全局变量最常见的设计问题。

4. 小心初始化、析构与卸载

避免让多个动态库的全局构造函数互相依赖,因为跨翻译单元、跨动态库的初始化和析构顺序很难维护。

如果库可能被 dlclose()

  • 不要让外部线程继续执行库中的代码。
  • 不要保留指向库内静态对象、函数或 TLS 数据的悬空引用。
  • 在卸载前显式停止后台线程并释放回调。

5. 不要用 -Bsymbolic 掩盖接口设计问题

链接器选项可以改变库内符号绑定方式,但也可能破坏预期的符号覆盖、测试替换或继承关系。更直接的做法是隐藏内部符号,只公开稳定的函数接口。

七、如何验证实际映射?

查看动态库的 ELF 段权限:

readelf -lW libfoo.so
readelf -SW libfoo.so

查看导出的动态符号与重定位:

nm -D --defined-only libfoo.so
readelf -rW libfoo.so

查看进程中的映射:

pmap -x <pid>
cat /proc/<pid>/maps
cat /proc/<pid>/smaps

/proc/<pid>/smaps 中可以观察 Shared_CleanPrivate_Dirty 等统计,帮助理解哪些页被复用、哪些页因为写入或重定位变成了进程私有页。

面试总结

可以这样回答:

多个进程加载同一个动态库时,库的只读代码页和部分只读数据页通常可以共享物理内存,但 .data.bss、堆对象以及经过写时复制的页面属于各进程自己的状态。一个进程修改库中的普通全局变量,其他进程看不到。相反,同一进程中的线程共享动态库全局变量,所以并发写入必须使用原子操作或互斥量;thread_local 才是每线程一份。同一链接器命名空间中重复 dlopen() 同一个库通常只增加引用计数,不会产生新的全局变量副本。工程上应避免导出可写全局变量,优先使用上下文对象;确实需要进程级状态时,把变量隐藏在库内并通过函数访问,需要跨进程共享时则使用共享内存或其他 IPC。

最后记住五点:

  1. 代码页的物理共享,不等于全局状态跨进程共享。
  2. 普通全局变量通常是每进程一份、进程内线程共享。
  3. thread_local 是每线程一份。
  4. 重复 dlopen() 通常复用同一库实例,RTLD_LOCAL 不会复制状态。
  5. 动态库最好导出函数和对象句柄,不要直接导出可写全局变量。