vak: (Знайка)
[personal profile] vak
Давайте выясним, чем тип Arc<T> отличается от Rc<T>. Название означает Atomically Reference Counted. Как объясняет нам grok.com, эта штука почти эквивалентна std::shared_ptr из Си++20 и позже. Сгенерённый исходник смотрите в файле: arc.cpp

Question: How Arc<T> would look if implemented in C++?

Let’s dive into how we might implement something like Rust’s Arc<T> in C++. For those unfamiliar, Arc<T> in Rust is a thread-safe, reference-counted smart pointer that allows multiple owners of some data, incrementing a counter when cloned and decrementing it when dropped, freeing the data when the count hits zero. It’s built for concurrent use, so it uses atomic operations to manage the count safely across threads. We’ll aim to replicate that behavior in C++.

In C++, we don’t have a direct equivalent in the standard library, but std::shared_ptr comes close. However, std::shared_ptr isn’t guaranteed to use atomic reference counting for the control block in all implementations (pre-C++20, at least), and its atomic operations were only standardized with std::atomic<std::shared_ptr> in C++20. To truly mirror Arc<T>, we’ll roll our own simplified version using atomic counters, focusing on thread-safety and the core mechanics.

Here’s how it could look:
#include <atomic>
#include <memory>
#include <utility>
#include <type_traits>

// Define Inner as a standalone template struct
template <typename T>
struct ArcInner {
T data;
std::atomic<int> ref_count;

template <typename... Args>
ArcInner(Args&&... args) : data(std::forward<Args>(args)...), ref_count(1) {}
};

template <typename T>
class Arc {
private:
ArcInner<T>* ptr;

void increment_count() {
if (ptr) {
ptr->ref_count.fetch_add(1, std::memory_order_relaxed);
}
}

void decrement_count() {
if (ptr) {
if (ptr->ref_count.fetch_sub(1, std::memory_order_acq_rel) == 1) {
delete ptr;
ptr = nullptr;
}
}
}

public:
// Constructor with constraint: Args must be convertible to T
template <typename... Args, typename = std::enable_if_t<std::is_constructible_v<T, Args...>>>
explicit Arc(Args&&... args) : ptr(new ArcInner<T>(std::forward<Args>(args)...)) {}

// Copy constructor
Arc(const Arc& other) : ptr(other.ptr) {
increment_count();
}

// Move constructor
Arc(Arc&& other) noexcept : ptr(other.ptr) {
other.ptr = nullptr;
}

// Assignment operators
Arc& operator=(const Arc& other) {
if (this != &other) {
decrement_count();
ptr = other.ptr;
increment_count();
}
return *this;
}

Arc& operator=(Arc&& other) noexcept {
if (this != &other) {
decrement_count();
ptr = other.ptr;
other.ptr = nullptr;
}
return *this;
}

// Destructor
~Arc() {
decrement_count();
}

// Dereference operators
T& operator*() const { return ptr->data; }
T* operator->() const { return &ptr->data; }

// Get the reference count
int use_count() const { return ptr ? ptr->ref_count.load(std::memory_order_relaxed) : 0; }
};

// Helper function to create an Arc
template <typename T, typename... Args>
Arc<T> make_arc(Args&&... args) {
return Arc<T>(std::forward<Args>(args)...);
}
Let’s break this down and compare it to Rust’s Arc<T>:

Key Components

1. Inner Structure:
  • We define an Inner struct to hold the data (T) and the atomic reference count (std::atomic<int>). This mimics how Arc<T> in Rust bundles the data with its reference counter.
  • The constructor uses variadic templates and perfect forwarding to construct T in-place, just like Arc::new.
2. Atomic Reference Counting:
  • increment_count uses fetch_add with memory_order_relaxed since we only need to increment the count, and no other memory synchronization is required at that point.
  • decrement_count uses fetch_sub with memory_order_acq_rel to ensure proper synchronization: "acquire" for reading the last value and "release" for ensuring prior writes are visible before deletion. If the count drops to 1 (meaning this was the last reference), we delete the Inner object.
  • This matches Arc<T>’s use of atomic operations (like AtomicUsize in Rust) for thread-safety.
3. Constructors and Assignment:
  • The copy constructor and assignment operator increment the count, ensuring shared ownership.
  • The move constructor and move assignment transfer ownership without changing the count, setting the source to nullptr, similar to how Rust handles moves (though Arc is typically cloned, not moved).
  • This mirrors Arc’s Clone trait behavior in Rust.
4. Destructor:
  • Decrements the count and cleans up if it’s the last reference, just like Arc’s Drop implementation.
5. Access:
  • Dereference operators (* and ->) provide access to the underlying data, akin to Rust’s Deref trait.

Example Usage

#include <iostream>
#include <thread>

// Test function for threading
void thread_func(Arc<int> arc) {
std::cout << "Thread value: " << *arc << ", count: " << arc.use_count() << "\n";
}

int main() {
auto arc = make_arc<int>(42); // Create Arc with int value 42
std::cout << "Main value: " << *arc << ", count: " << arc.use_count() << "\n";

std::thread t(thread_func, arc);
t.join();

std::cout << "After thread, count: " << arc.use_count() << "\n";
return 0;
}

Output (example):

Main value: 42, count: 1
Thread value: 42, count: 2
After thread, count: 1

How It Compares to Arc<T>

  • Thread-Safety: Like Arc<T>, this uses atomic operations, making it safe for sharing across threads without data races on the reference count.
  • Ownership: Multiple Arcs can own the same data, and it’s freed when the last one is destroyed, just like in Rust.
  • Differences:
    • Rust’s Arc<T> integrates with the borrow checker, which C++ lacks, so we rely on manual discipline to avoid dangling pointers or misuse.
    • Rust provides Arc::try_unwrap to extract the inner value if the count is 1; here, you’d need to add a similar method manually.
    • C++’s std::shared_ptr already exists and is more polished (with weak_ptr support), but its pre-C++20 atomic behavior isn’t guaranteed, whereas this explicitly uses atomics like Arc.

Limitations

  • No weak references (Rust’s Weak<T> equivalent would need another class).
  • Simplistic memory ordering; a production version might need more careful tuning (e.g., memory_order_seq_cst for stricter guarantees).
  • No custom allocators or advanced features Rust’s Arc might support internally.
So, this is a bare-bones Arc<T> in C++ - functional, thread-safe, and close in spirit to Rust’s design. If you wanted to extend it, you could add weak pointers or more Rust-like utilities, but this captures the essence!

Date: 2025-02-28 06:03 (UTC)
From: [personal profile] sassa_nf
This is not thread-safe:
if (ptr) {
            if (ptr->ref_count.fetch_sub(1, std::memory_order_acq_rel) == 1) {
                delete ptr;
                ptr = nullptr;
            }
        }

Date: 2025-02-28 08:11 (UTC)
juan_gandhi: (Default)
From: [personal profile] juan_gandhi
This too.

Date: 2025-02-28 09:04 (UTC)
From: [personal profile] sassa_nf
The design should have the counter outside the wrapped pointer, so you can always check if the pointer is usable.

Imagine one thread increments the counter, and another decrements it.

Suspend the decrementing thread right after delete but before ptr is nulled. Now increment is going to mutate some other object that got allocated in place of the wrapper.

Or: suspend increment just before the atomic operation. Now suspend the decrement right after it decrements the counter, and resume the incrementing thread. The incrementing thread will use ptr like it is live, whereas the decrementing thread has destroyed the object.

Date: 2025-02-28 09:29 (UTC)
From: [personal profile] sassa_nf
The whole thing works in Rust only because the language enforces no sharing of mutable references. So you can't share the same Arc object between threads, you've got to clone it. Maybe some C++ feature enforces the same behaviour for Arc, I just don't know enough.

Date: 2025-02-28 17:18 (UTC)
spamsink: (Default)
From: [personal profile] spamsink
If there are two threads knowing the object, the counter cannot be 1.

Date: 2025-02-28 18:19 (UTC)
From: [personal profile] sassa_nf
...and ptr can't be null, right? So no need to check for it. But if they check for it, then sometimes the functions are accessible when ptr is null, and the logic above applies.

But I think the trick is in not sharing references to Arc. Does the generated code guarantee that? (In rust that's out of the box)
Edited Date: 2025-02-28 18:20 (UTC)

Date: 2025-02-28 18:40 (UTC)
spamsink: (Default)
From: [personal profile] spamsink
In C++, if one really wants to shoot oneself in the foot, it is always possible, but you'll need to be very dedicated in doing that, e. g. by passing non-const refs to Arc to another thread, and calling the destructor explicitly.

Date: 2025-02-28 08:10 (UTC)
juan_gandhi: (Default)
From: [personal profile] juan_gandhi
В принципе чистенько - но почему не проверяется, что счётчик пуст и пойнтер нулевой? Непанятна.