Skip to content

net: replace manual reference counting of CNode with shared_ptr#32015

Open
vasild wants to merge 7 commits into
bitcoin:masterfrom
vasild:cnode_shared_ptr
Open

net: replace manual reference counting of CNode with shared_ptr#32015
vasild wants to merge 7 commits into
bitcoin:masterfrom
vasild:cnode_shared_ptr

Conversation

@vasild

@vasild vasild commented Mar 7, 2025

Copy link
Copy Markdown
Contributor

Before this change the code used to count references to CNode objects manually via CNode::nRefCount. Unneeded CNodes were scheduled for deletion by putting them in CConnman::m_nodes_disconnected and were deleted after their reference count reached zero. Deleting consists of calling PeerManager::FinalizeNode() and destroying the CNode object.

Replace this scheme with std::shared_ptr. This simplifies the code and removes:
CNode::nRefCount
CNode::GetRefCount()
CNode::AddRef()
CNode::Release()
CConnman::m_nodes_disconnected
CConnman::NodesSnapshot

Now creating a snapshot of CConnman::m_nodes is done by simply copying it (under the mutex).

Call PeerManager::FinalizeNode() from the destructor of CNode, which is called when the reference count reaches 0.

This removes brittle code and replaces it by a code from the standard library which is more robust. All the below are possible with the manual reference counting and not possible with shared_ptr:

  • Use an object without adding a reference to it
  • Add too many references to an object, mismatch the add/remove by having more "add"
  • Forget to reduce the reference count, mismatch the add/remove by having less "remove"
  • Go with negative reference count by mistakenly having too many "remove"
  • Destroy an object while there are references to it

There is zero learning curve for the average C++ programmer who knows how shared_ptr works. Not so much with the manual reference counting because one has to learn about AddRef(), Release(), check and maintain where are they called and their relationship with m_nodes_disconnected.


This has some history in #28222 and #10738. See below #32015 (comment) and #32015 (comment).


Possible followup: change ProcessMessages() and SendMessages() to take CNode&: #32015 (comment)

@DrahtBot

DrahtBot commented Mar 7, 2025

Copy link
Copy Markdown
Contributor

The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

Code Coverage & Benchmarks

For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/32015.

Reviews

See the guideline for information on the review process.

Type Reviewers
Concept ACK hodlinator, theuni
Stale ACK pinheadmz

If your review is incorrectly listed, please copy-paste <!--meta-tag:bot-skip--> into the comment that the bot should ignore.

Conflicts

Reviewers, this pull request conflicts with the following ones:

  • #33956 (net: fix use-after-free with v2->v1 reconnection logic by Crypt-iQ)
  • #32394 (net: make m_nodes_mutex non-recursive by vasild)
  • #32278 (doc: better document NetEventsInterface and the deletion of "CNode"s by vasild)
  • #32061 (Replace libevent with our own HTTP and socket-handling implementation by pinheadmz)
  • #30988 (Split CConnman by vasild)
  • #29415 (Broadcast own transactions only via short-lived Tor or I2P connections by vasild)

If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

@DrahtBot DrahtBot added the P2P label Mar 7, 2025
@vasild

vasild commented Mar 7, 2025

Copy link
Copy Markdown
Contributor Author

cc @willcl-ark, @dergoegge, @theuni

@vasild

vasild commented Mar 7, 2025

Copy link
Copy Markdown
Contributor Author

cc @hodlinator, you might be interested in this as well

@fanquake

fanquake commented Mar 7, 2025

Copy link
Copy Markdown
Member

https://github.com/bitcoin/bitcoin/actions/runs/13718139296/job/38367326902?pr=32015#step:7:1603:

Run p2p_headers_presync with args ['/Users/runner/work/bitcoin/bitcoin/ci/scratch/build-aarch64-apple-darwin23.6.0/src/test/fuzz/fuzz', PosixPath('/Users/runner/work/bitcoin/bitcoin/ci/scratch/qa-assets/fuzz_corpora/p2p_headers_presync')]net_processing.cpp:295 SetTxRelay: Assertion `!m_tx_relay' failed.
Error processing input "/Users/runner/work/bitcoin/bitcoin/ci/scratch/qa-assets/fuzz_corpora/p2p_headers_presync/c12f87158cda99b4fb2e1fe7312af392a1f4634a"

net_processing.cpp:295 SetTxRelay: Assertion `!m_tx_relay' failed.
Error processing input "/Users/runner/work/bitcoin/bitcoin/ci/scratch/qa-assets/fuzz_corpora/p2p_headers_presync/c12f87158cda99b4fb2e1fe7312af392a1f4634a"

Comment thread src/net.cpp Outdated
// after m_nodes_mutex has been released. Destructing a node involves
// calling m_msgproc->FinalizeNode() which acquires cs_main. Lock order
// should be cs_main, m_nodes_mutex.
disconnected_nodes.push_back(node);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that ~CNode is responsible for calling FinalizeNode, the guarantees around which thread does the actual cleanup are lost. It's basically just whichever thread ends up holding the last instance of a CNode. This seems bug prone.

E.g. there are no guarantees (afaict) that disconnected_nodes will actually be the last owner.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea is that it does not matter which thread will destroy the CNode objects.

there are no guarantees (afaict) that disconnected_nodes will actually be the last owner.

That is right, but there is no need for such a guarantee. disconnected_nodes is to guarantee that the objects will not be destroyed while holding m_nodes_mutex.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In thread #32015 (comment):

Maybe one could have an additional vector<shared_ptr<CNode>> member in CConnman beyond m_nodes that always keeps the shared_ptr::use_count() above 0. Then use removal from that vector (when use_count() == 1) for controlled deletion with enforced lock order, more like how the code was before?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not think that is necessary. I mean - if we would assume that the code is going to be modified in a careless way to break the logic, in other words to destroy CNode objects while holding m_nodes_mutex, then we might as well assume that the code could be modified in a way that breaks the manual reference counting. IMO the manual reference counting is more fragile and error prone.

Further, if the code is changed to destroy CNode objects while holding m_nodes_mutex, then 83 functional tests fail. I think it is safe to assume that such a thing will not go unnoticed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In thread #32015 (comment):

Started proving out my suggestion and landed on something closer to master.

  • Resurrect m_nodes_disconnected (now holding shared_ptr) along with clearly defined lifetimes.
  • Resurrect DeleteNode(), but make it take an r-value shared_ptr and assert that use_count() == 1.
  • Avoid adding ~CNode() and CNode::m_destruct_cb along with sending it in everywhere in the constructor. This means we don't need to touch 5 of the test files.

master...hodlinator:bitcoin:pr/32015_suggestion

Passes unit tests and functional tests.

@DrahtBot

DrahtBot commented Mar 7, 2025

Copy link
Copy Markdown
Contributor

🚧 At least one of the CI tasks failed.
Debug: https://github.com/bitcoin/bitcoin/runs/38367328595

Hints

Try to run the tests locally, according to the documentation. However, a CI failure may still
happen due to a number of reasons, for example:

  • Possibly due to a silent merge conflict (the changes in this pull request being
    incompatible with the current code in the target branch). If so, make sure to rebase on the latest
    commit of the target branch.

  • A sanitizer issue, which can only be found by compiling with the sanitizer and running the
    affected test.

  • An intermittent issue.

Leave a comment here, if you need help tracking down a confusing failure.

@vasild vasild force-pushed the cnode_shared_ptr branch from 2747f13 to e35a382 Compare March 7, 2025 13:22
@vasild

vasild commented Mar 7, 2025

Copy link
Copy Markdown
Contributor Author

2747f135be...e35a382388: fix the CI failure, thanks!

@hodlinator hodlinator left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concept ACK 36d932cebbb235a79e90575960646e86a23d39a8

Comment thread src/net.cpp Outdated
Comment thread src/net.cpp Outdated
// after m_nodes_mutex has been released. Destructing a node involves
// calling m_msgproc->FinalizeNode() which acquires cs_main. Lock order
// should be cs_main, m_nodes_mutex.
disconnected_nodes.push_back(node);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In thread #32015 (comment):

Maybe one could have an additional vector<shared_ptr<CNode>> member in CConnman beyond m_nodes that always keeps the shared_ptr::use_count() above 0. Then use removal from that vector (when use_count() == 1) for controlled deletion with enforced lock order, more like how the code was before?

@vasild

vasild commented Mar 10, 2025

Copy link
Copy Markdown
Contributor Author

36d932cebb...af622d00ba: address #32015 (comment)

@purpleKarrot

Copy link
Copy Markdown
Contributor

Instead of passing around shared pointers to mutable data (aka global variables in disguise), would it be possible to increase the level of abstraction and pass around values with regular semantics? Every C++ programmer should rewatch https://youtu.be/W2tWOdzgXHA from time to time.

@hodlinator

Copy link
Copy Markdown
Contributor

Every C++ programmer should rewatch https://youtu.be/W2tWOdzgXHA from time to time.

I remembered that as "the seminal std::rotate()-talk", but it also has "No Raw Pointers" from 48:45. Probably stoked my hatred of smart pointers which led to #31713. To be fair, in the talk he also says that we used to do intrusive ref-counting... so this is at least one step away from that.

Instead of passing around shared pointers to mutable data (aka global variables in disguise), would it be possible to increase the level of abstraction and pass around values with regular semantics?

Guess one could have an InternalCNode which is the actual meat, and make the public CNode instances hold shared_ptr<InternalCNode>. Not sure how well that plays out.
Maybe you @purpleKarrot could take a stab at something along the lines of what you pointed to? Keep in mind that we try to minimize the number of lines touched in a change unless necessary.

@purpleKarrot

Copy link
Copy Markdown
Contributor

After reviewing the code line by line, I realized that the only places where AddRef was ever called, was directly after construction (plus one place in the fuzz tests, not sure why that is needed). It looks like the CNode instances are not actually shared (good news). Could it be passed around by std::unique_ptr instead? This would greatly simplify future refactoring.

@dergoegge

Copy link
Copy Markdown
Member

realized that the only places where AddRef was ever called, was directly after construction

It is also called in the RAII helper NodesSnapshot which is used in different threads (i.e. "net" and "msghand"), so unique_ptr won't work unfortunately.

plus one place in the fuzz tests, not sure why that is needed

It's probably just there to have it "covered".

Comment thread src/test/util/net.h
Comment thread src/net.cpp Outdated
Comment thread src/net.cpp Outdated
Comment thread src/net.cpp Outdated
@purpleKarrot

Copy link
Copy Markdown
Contributor

unique_ptr won't work unfortunately.

Got it. m_nodes needs to be able to be snapshotted. But that seems to be the only place where copies are made. FindNode should return non-owning observers rather than extend the ownership.

@vasild

vasild commented Sep 29, 2025

Copy link
Copy Markdown
Contributor Author

0cd86a5c70...8218daac0f: rebase due to conflicts

@vasild

vasild commented Oct 2, 2025

Copy link
Copy Markdown
Contributor Author

8218daac0f...d026d5475e: rebase due to conflicts

This is now a little bit simplified after #32326 was merged - no need to touch FindNode() anymore.

vasild and others added 7 commits October 7, 2025 11:31
This is a non-functional change - the manual reference counting,
disconnection, finalization and deletion are unchanged.

`DeleteNode()` used to call `FinalizeNode()` + `delete` the raw pointer.
Expand `DeleteNode()` at the call sites - call `FinalizeNode()` at the
call sites and instead of raw `delete` either get the container vector
go out of scope or explicitly call `clear()` on it.
…aries

The other copies have a purpose for locking, these were just an awkward
convenience for deleting while iterating.

Co-authored-by: Vasil Dimov <vd@FreeBSD.org>
Subsequent commits will allow this to be shared with the message
handling thread.

Co-authored-by: Vasil Dimov <vd@FreeBSD.org>
Rather than relying on refcounting to abstract away our teardown
procedure, make the rules explicit:
- FinalizeNode() is only called when ProcessMessages() and
  SendMessages() is not running. This is enforced by finalizing only at
  the top of the message handler loop.
- FinalizeNode() may only be called on nodes which have been
  disconnected, by virtue of finding them in m_nodes_disconnected. This
  enforces the order: disconnect -> finalize -> delete. This order is
  the current behavior but is not strictly necessary, and the
  restriction will be removed in a later commit.
- Nodes are only deleted after they've been disconnected and finalized.
  This is enforced by deleting them from m_nodes_disconnected and only
  after calling FinalizeNode().
- Callers of ForNode() and ForEachNode() are currently protected from
  finalization or deletion due to their locking of m_nodes_mutex.

Co-authored-by: Vasil Dimov <vd@FreeBSD.org>
Snapshots of `m_nodes` are not needed anymore because finalization and
deletion of nodes only happens in `ThreadMessageHandler()` and only a
after nodes have been disconnected and moved from `m_nodes` to
`m_nodes_disconnected`.

The manual reference counting becomes unused, remove it too.

Co-authored-by: Cory Fields <cory-nospam-@coryfields.com>
The shared_ptrs now ensure that the CNodes will not be deleted for the
duration of the callbacks.

Disconnection/Finalization may happen while the shared_ptr is being held,
though it won't be deleted for the duration of the callback.

The majority of uses are in ProcessMessages and SendMessages, during which
disconnection may occur but FinalizeNode will not be called.

Co-authored-by: Vasil Dimov <vd@FreeBSD.org>
This keeps us from waiting for the condvar timeout (currently 100ms) before
finalizing a node that has been disconnected on the socket handler thread.

Co-authored-by: Vasil Dimov <vd@FreeBSD.org>
@vasild

vasild commented Oct 7, 2025

Copy link
Copy Markdown
Contributor Author

d026d5475e...abc29c4d8b: rebase due to conflicts

@DrahtBot

DrahtBot commented Dec 5, 2025

Copy link
Copy Markdown
Contributor

🐙 This pull request conflicts with the target branch and needs rebase.

@sedited

sedited commented Feb 13, 2026

Copy link
Copy Markdown
Contributor

This has been in need of a rebase for a while. Can you push an update?

@DrahtBot

Copy link
Copy Markdown
Contributor

⌛ There hasn't been much activity lately and the patch still needs rebase. What is the status here?

  • Is it still relevant? ➡️ Please solve the conflicts to make it ready for review and to ensure the CI passes.
  • Is it no longer relevant? ➡️ Please close.
  • Did the author lose interest or time to work on this? ➡️ Please close it and mark it with one of the labels 'Up for grabs' or 'Insufficient Review', so that it can be picked up in the future.

@sedited

sedited commented May 14, 2026

Copy link
Copy Markdown
Contributor

@vasild are you still working on this?

@vasild

vasild commented May 15, 2026

Copy link
Copy Markdown
Contributor Author

Whenever I need to use a pointer, I increment a reference count to make sure nobody deletes it while I use it. When I am done with it, I decrement the reference counter. Whenever the counter drops to zero, the object is deleted. This is what happens in master currently. Because this is exactly what std::shared_ptr does, this PR replaces our home-made shared pointer with the one from the C++ standard library.

I think such a simplification is worth the effort to rebase, resolve conflicts and give some love to this PR. However, it is also an energy drain. Last status seems to be that I incorporated the feedback and then it was suggested it might be better to do another bigger change. That was one year ago.

@theuni, would you be ok with this in 2026? If yes, then I will bring it up to date. If no, then I will close it because it will go nowhere anyway.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.