在 C 语言多线程环境中使用句柄和侵入式引用计数器





5.00/5 (4投票s)
C 语言智能指针实现
引言
在多个线程中访问同一数据被认为是不良实践,但在许多情况下这是不可避免的,这里讨论的不是这个问题。这里讨论的问题是如何以最安全的方式组织这种访问。
在多线程环境中使用对象或数据结构时,除了其他方面,主要的关注点之一是确保对象仍然存活且分配给该结构的内存未被释放。这可以通过多种方式实现,我们将讨论的是句柄和引用计数器。
句柄是包含数据对象指针和辅助数据的小型结构,用于确保对象仍然存活。通常,有两个函数用于操作句柄:lock_handle
和 unlock_handle
(名称是随意选择的,仅用于展示功能)。
Lock_handle
检查对象的可用性,递增原子引用计数,如果可用则返回数据对象的指针。如果不可用,它将返回 NULL
指针,或使用其他方式表示对象不再可用。根据其名称,unlock_handle
原子地递减引用计数,并且当它达到 0 时删除对象。侵入式引用计数器是嵌入在数据对象中的原子整数,它们表示程序中使用该对象的次数。一旦引用计数器达到值 0
,对象就会被删除。
现在让我们看看这两种策略的优缺点和常见陷阱,并确定在特定情况下选择哪一种更好的方法。让我们首先看看侵入式引用计数器。
侵入式引用计数器
侵入式引用计数器,顾名思义,需要对被计数的数据结构进行“侵入”:进行修改,添加计数器。对于一个简单的结构,我们称之为 data_packet_s
,修改可能看起来像这样
struct data_packet_s {
void *data_buffer;
size_t data_buffer_size;
};
|
=> |
struct data_packet_s {
void *data_buffer;
size_t data_buffer_size;
volatile int reference_count;
};
|
因此,这是这种方法的缺点:它需要修改对象,因此我们只能将其用于我们可以修改的数据结构。我们无法将其用于任意库结构。
有趣的是,这同一事实也将成为一个优点。优点是我们不需要任何额外的结构,也不需要为这些结构分配任何额外的内存。
另一个缺点,或者说是这种方法的特殊性是:在递增引用计数器时必须保证对象的可用性。换句话说,我们不能简单地存储指向对象的指针并等待需要访问它的时候,然后尝试递增引用计数器并使用对象,因为在我们存储指针和开始使用它之间,对象可能已经被销毁。让我们使用一种简单的常规语言演示这种情况的最简单情况。
线程 1 | 线程 2 |
创建 object1 |
|
递增对 object1 的引用 |
|
启动线程 2,将 object1 作为参数传递 |
Start |
递减对 object1 的引用,引用计数达到 0 |
存储 object1 |
删除 object1 以及侵入式引用计数器 |
Wait |
递增引用计数器 => 内存访问冲突 |
在这种情况下,为了确保对象存活,您必须在线程 1 中递增引用计数,并将此特定实例的所有权传递给线程 2(在不再需要对象后,在线程 2 中递减 refcount
)。换句话说,如果需要在另一个线程中处理对象一次然后就忘记它,这种方案可以很好地工作。这可以通过以下用例来演示
线程 1 | 线程 2 |
创建 object1 |
|
递增对 object1 的引用 |
|
启动线程 2,将 object1 作为参数传递 |
Start |
存储 object1 |
|
处理 object1 |
|
递减引用计数器,refcount 达到 0 |
|
删除 object1 |
此方案可以与任意数量的线程一起使用,前提是每个递增引用计数器的线程都拥有至少一个实例(已递增引用计数器且未传递实例所有权,或从另一个线程接收了实例所有权,就像上一个演示案例中的线程 2)。因此在最后一个案例中,线程 1 可以多次递增引用(每个线程一次),并且仅在此之后,它才能启动多个线程,每个线程对应 refcount
递增的次数。
因此,出现了第二个缺点:您在不同的线程中递增和递减引用计数器,这极易出错。它可能导致各种错误,从我们未能释放计数器时的内存泄漏,到某些线程中可能发生的双重释放,导致错误地释放对象,从而在其他线程仍在使用它时删除它。
这种方案引入了所谓的“共享所有权”,即线程共同拥有对象,最后一个递减引用计数器的线程将删除对象。
如果你想在另一个线程中存储指向对象的指针并在需要时使用它,你会发现程序的内存使用量会随着存储的每个对象而增长,因为存储引用的线程永远不会释放它,因为它必须确保对象是活动的。这种方法的另一个陷阱是访问间接传递的对象,即通过包含在其他直接或间接传递的对象中的指针传递的对象,这些对象引用但不拥有该对象。所有这些对象都应仔细注意并进行引用计数,并由传递或接收这种间接引用的每个工作线程拥有。因此,让我们总结一下侵入式引用计数器方法的缺点和优点
缺点 | 优点 |
它是侵入式的,即需要修改对象 | 不需要额外的结构,增加了内存操作的数量 |
必须持有引用以确保对象及其内存仍然有效 | |
引用的所有权必须在线程之间传递 | |
需要仔细跟踪间接引用的对象 |
句柄
句柄是轻量级结构,按值传递,它们引用对象,管理引用并确保对象完整性。
显然,引用同一对象的句柄将共享相同的引用计数器对象。这通常意味着这样的引用计数器必须在堆上分配。首先想到的一种简单句柄结构如下所示
struct handle_s {
volatile int *reference_count;
void *object;
};
其中 reference_count
在创建第一个句柄时分配。这种方法的第一个缺点是显而易见的。我们需要管理一个额外的结构,为引用计数器分配另一个内存块。但是当你想将引用计数与你无法访问的结构一起使用时,它是值得的。
这种句柄的典型用法如下:
struct some_struct_s *object = lock_handle(hdata);
if(object) {
use(object);
release_handle(hdata);
}
让我们看看当对象的最后一个引用消失时会发生什么。首先,我们显然希望删除托管对象。如果对象是一个简单的内存区域,我们只想 free()
对象使用的内存。但在大多数情况下,并非如此。就像前面提到的 data_packet_s
示例一样,我们还希望 free()
data_buffer
内存。如果我们只为一种对象类型使用句柄,那不会造成大问题。但如果我们要处理不同类型,这又引出了另一个问题:如何删除被处理的对象?
现在我们可以在句柄中添加另一条信息:用于销毁/释放被处理对象的函数指针。现在句柄看起来像这样:
struct handle_s {
volatile int *reference_count;
void *object;
void (*destroy_object)(void*);
};
现在 release_handle
无需知道对象的具体细节即可将其删除,它只需使用正确的函数,而我们已经从句柄中知道了这一点。让我们回到当最后一个引用被释放时会发生什么。
然后我们希望释放 reference_count
所占用的内存。但这将是极其错误的。如果句柄的其他实例仍然存在,它们将引用已经删除的 reference_count
。在下次尝试锁定句柄时,我们将访问已经释放的内存。
有没有一种解决方案可以避免内存不断丢失,同时仍能保持对所有共享引用计数器访问的有效性?有。它是一个对象池,用于管理空闲引用计数器。尽管这里又出现了一个问题,称为 ABA 问题。想象一下这样一种情况:你有一个指向对象的句柄。然后一个线程删除了该对象。然后创建了另一个对象并获取了一个句柄来管理它。会发生什么?
当对象被销毁时,与此对象(我们称之为 object1
)关联的 reference_count
将以值 0
释放回对象池。到目前为止一切都很好。但是当为某个新对象(我们称之为 object2
)分配另一个句柄时,将从对象池中取出与此对象关联的 reference_count
,并将其 reference_count
设为 1
。现在想象一下,存储指向 object1
的句柄的线程试图获取指针。它会成功,因为 reference_count
不是 0
,尽管它现在属于 object2
。lock
函数将返回无效指针,程序将(幸运地)崩溃或(不幸地)损坏内存内容。
显然有一个解决方案,否则我就不会写这一切了。
我们需要让 handle_s
结构尽可能轻量,以便能够按值而不是按指针传递它,所以我们这样做:创建两个结构,其中一个将是“弱”句柄,即不受特定对象限制,另一个将是“强”句柄,即一个将与特定对象强绑定,如果与其绑定的“弱”句柄不再引用同一对象,则返回 NULL
。
我们这样定义它们
struct weak_handle_s {
volatile int version;
volatile int reference_count;
void object;
void (*destroy_object)(void*);
};
struct strong_handle_s {
struct weak_handle_s *handle;
int version;
};
所以如您所见,现在两个句柄都有一个“version
”字段,而 strong_handle_s
甚至没有对象指针,因为它现在存储在共享的 weak_handle_s
中。
让我们看看它是如何保护我们免受上面所示的 ABA 问题的困扰的。
引用同一对象的 strong_handle_s
和 weak_handle_s
的版本字段彼此相等。
每当句柄被释放并且 weak_handle_s
被放回对象池时,递增 weak_handle_s
中的版本号。
下次,如果获取 weak_handle_s
来处理另一个对象,它的版本号将与已释放的对象不同。现在在 lock_handle
函数中,通过比较弱句柄和强句柄中的版本字段,我们可以判断 weak_handle_s
是否仍然引用 strong_handle_s
所指的对象,如果不是,则返回 NULL
。
因此,我们可以看到,句柄方法带来了一些相当具有挑战性的问题,但它也有其光明的一面:句柄可以存储并被遗忘,直到我们需要它引用的对象;句柄是非侵入性的,这意味着我们几乎可以将其与任何数据类型一起使用。
此外,传递句柄不一定意味着传递对象的所有权,因此它可以替代指针,作为对象在任何不拥有对象(即数据结构等)的地方的安全引用。
所以句柄管理起来复杂得多,但功能也强大得多。
缺点 | 优点 |
需要额外的内存来管理句柄 | 允许根据需要存储句柄和访问对象 |
需要更多 CPU 周期 | 不需要实例所有权来确保可以请求引用的实例 |
带来了一些不明显的问题 |
结论
侵入式引用计数器实现了一个共享的强引用,它必须始终持有锁,直到对象确定不再需要。在所有引用解锁之前,对象不能被删除。如果一个引用锁被只持有一个引用锁的线程解锁,那么再获取锁是不安全的,因此无法保证对象所占用的内存以及它所包含的引用计数器不会被释放。
句柄实现了一个共享的弱引用。如果对象是活动的,lock_handle
函数将返回获取对象的指针,并且保证在句柄解锁之前对象不会被删除。锁定和解锁句柄无论多少次都是安全的,因为句柄作为一个独立的对象,被保证是一个有效的内存对象。应采取适当措施,以确保当对象达到引用计数 0
时,共享引用计数内存不会被释放,并且重新请求的引用计数不会用于检查已释放对象中的引用计数。