Loading src/event/epoll.cpp +23 −28 Original line number Diff line number Diff line Loading @@ -230,20 +230,12 @@ namespace netplus { ccon->slots[0].csock = std::make_unique<ssl>(*srv->_tls.cert_map, -1); } else if (_ServerSocket->_Type == sockettype::QUIC) { // QUIC: accept() drains up to 512 queued datagrams in // one call (see quic::accept). The listener fd is // registered EPOLLONESHOT (see runEventloop) so we're // the only thread servicing it right now — rearm on // scope exit (even if accept() throws) so the next // datagram wakes a thread again. int qfd = _ServerSocket->fd(); struct RearmOnExit { poll* p; int fd; ~RearmOnExit() { try { p->setpollEventsFd(fd, EPOLLIN | EPOLLONESHOT); } catch (...) {} } } rearm{this, qfd}; // one call (see quic::accept). The listener fd is level // triggered without EPOLLONESHOT (see runEventloop), so // no rearm is needed here — and no rearm-with-ONESHOT // should happen here either, since that would silently // put the fd back into single-thread-at-a-time mode // after the first connection event. std::unique_ptr<socket> quic_csock; _ServerSocket->accept(quic_csock, true); return; Loading Loading @@ -808,20 +800,23 @@ namespace netplus { epoll_event setevent{}; setevent.events = EPOLLIN; // QUIC multiplexes every client (and all of their ongoing // traffic) over this one UDP socket for the connection's // whole lifetime — unlike TCP/SSL, where only accept() shares // the listener and established connections get their own // EPOLLONESHOT'd fd. Without ONESHOT here, every incoming // datagram wakes all `threads` workers (level-triggered, // shared epoll fd) — and since quic_mtx() is one lock per // listener (see socket.h), only one of them can ever do // anything useful; the rest just contend on that mutex for // nothing. ONESHOT + explicit rearm in ConnectEventHandler // limits servicing to one thread at a time, same as TCP/SSL // already do per-connection. if (ss->_Type == sockettype::QUIC) { setevent.events |= EPOLLONESHOT; } // traffic) over this one UDP socket for the connection's whole // lifetime — unlike TCP/SSL, where only accept() shares the // listener and established connections get their own // EPOLLONESHOT'd fd. This used to also be EPOLLONESHOT'd for // QUIC, because quic_mtx() was one lock shared by every // connection on the listener (see socket.h's history) — level // triggered + shared epoll fd meant every datagram woke all // `threads` workers, and only one could ever do anything // useful while the rest contended on that single mutex for // nothing. Each connection now has its own quic_mtx(), and // _child_connections lookups/mutations go through their own // short-lived _registry_mutex (see quic::accept()), so // multiple threads calling accept() concurrently on this fd // now actually process different connections in parallel // instead of serializing on one lock — recvmmsg on the same // fd from multiple threads is safe (the kernel hands out // disjoint datagrams, never duplicates). setevent.data.fd = ss->fd(); if (epoll_ctl(_pollFD, EPOLL_CTL_ADD, ss->fd(), &setevent) < 0) { Loading src/quic.cpp +281 −151 File changed.Preview size limit exceeded, changes collapsed. Show changes src/socket.h +39 −19 Original line number Diff line number Diff line Loading @@ -31,6 +31,7 @@ #include <iostream> #include <atomic> #include <mutex> #include <shared_mutex> #include <string> #include <memory> #include <deque> Loading Loading @@ -996,20 +997,34 @@ namespace netplus { std::map<std::string, ssl::CertificateBundle> _cert_map; ssl::CertificateBundle* _selected_cert = nullptr; // Mutex for thread safety (recursive: musl returns EDEADLK on // double-lock by same thread, which happens when processFrame // callbacks re-enter locking functions like sendStreamData). // Declared before _child_connections/_child_owned_connections // deliberately: members are destroyed in reverse declaration // order, and ~quic() (run for every child when // _child_owned_connections is torn down) locks quic_mtx() — // which for a child resolves to _parent->_quic_mutex. If this // were declared after the child containers (as it used to be), // the parent's _quic_mutex would already be destroyed by the // time its children's destructors ran, locking an already-dead // mutex. // Mutex for this connection's own state (recursive: musl returns // EDEADLK on double-lock by same thread, which happens when // processFrame callbacks re-enter locking functions like // sendStreamData). Each quic object — listener or child — has and // uses its own; unlike the old design, a child's quic_mtx() no // longer delegates to the parent's, so different connections on // the same listener can be processed by different threads at the // same time (see _registry_mutex below for what still needs a // shared lock, and epoll.cpp for the EPOLLONESHOT removal this // enables). Declared before _child_connections deliberately: // members are destroyed in reverse declaration order, and // ~quic() (run for every child when _child_connections' last // shared_ptr reference to it drops) locks quic_mtx() on itself — // harmless now that it's not shared with the parent, but keeping // the ordering avoids relying on that distinction. mutable std::recursive_mutex _quic_mutex; // Guards _child_connections only (registry membership), never a // connection's own state — that's quic_mtx()'s job. shared_mutex // because lookups (every packet routed to an existing connection) // vastly outnumber inserts/erases (new/closed connections): many // threads can look up different — or the same — connections // concurrently via a shared_lock, and only take the exclusive // unique_lock for the rare registry mutation. Declared before // _child_connections for the same destruction-order reason as // _quic_mutex above. mutable std::shared_mutex _registry_mutex; // Connection tracking for server mode (maps DCID to child quic*). // Keyed by the raw CID bytes as a std::string (see cidKey() in // quic.cpp) rather than std::vector<uint8_t>: same correctness Loading @@ -1018,10 +1033,15 @@ namespace netplus { // byte-wise tree comparisons of the old std::map, and a CID that // fits in std::string's small-string buffer needs no heap // allocation for the lookup, unlike a std::vector key. // _child_connections maps DCID to raw pointer for fast lookup // _child_owned_connections vector owns the lifetime via unique_ptr std::unordered_map<std::string, quic*> _child_connections; std::vector<std::unique_ptr<quic>> _child_owned_connections; // Owns child connection lifetimes // Holds shared_ptr rather than a raw pointer + separate owning // vector: each connection is registered under two keys (original // DCID and our local CID) that share ownership of the same // object, and a caller that finds an entry can copy the // shared_ptr out (a "pin") before releasing _registry_mutex, // keeping the connection alive across the handoff to its own // quic_mtx() even if another thread concurrently erases both // registry entries for it. std::unordered_map<std::string, std::shared_ptr<quic>> _child_connections; quic* _parent = nullptr; // For child connections, pointer to parent listening socket // Stream data callback for application layer (quic-specific internal type) Loading Loading @@ -1119,10 +1139,10 @@ namespace netplus { }; std::vector<BatchEntry> _send_batch; // Children delegate to parent's mutex so there is only ONE lock // per QUIC listener — eliminates lock-ordering complexity. // Each connection's own lock — see _quic_mutex's comment for why // this no longer delegates to the parent. std::recursive_mutex& quic_mtx() const { return _parent ? _parent->_quic_mutex : _quic_mutex; return _quic_mutex; } // Friend classes for event handling Loading Loading
src/event/epoll.cpp +23 −28 Original line number Diff line number Diff line Loading @@ -230,20 +230,12 @@ namespace netplus { ccon->slots[0].csock = std::make_unique<ssl>(*srv->_tls.cert_map, -1); } else if (_ServerSocket->_Type == sockettype::QUIC) { // QUIC: accept() drains up to 512 queued datagrams in // one call (see quic::accept). The listener fd is // registered EPOLLONESHOT (see runEventloop) so we're // the only thread servicing it right now — rearm on // scope exit (even if accept() throws) so the next // datagram wakes a thread again. int qfd = _ServerSocket->fd(); struct RearmOnExit { poll* p; int fd; ~RearmOnExit() { try { p->setpollEventsFd(fd, EPOLLIN | EPOLLONESHOT); } catch (...) {} } } rearm{this, qfd}; // one call (see quic::accept). The listener fd is level // triggered without EPOLLONESHOT (see runEventloop), so // no rearm is needed here — and no rearm-with-ONESHOT // should happen here either, since that would silently // put the fd back into single-thread-at-a-time mode // after the first connection event. std::unique_ptr<socket> quic_csock; _ServerSocket->accept(quic_csock, true); return; Loading Loading @@ -808,20 +800,23 @@ namespace netplus { epoll_event setevent{}; setevent.events = EPOLLIN; // QUIC multiplexes every client (and all of their ongoing // traffic) over this one UDP socket for the connection's // whole lifetime — unlike TCP/SSL, where only accept() shares // the listener and established connections get their own // EPOLLONESHOT'd fd. Without ONESHOT here, every incoming // datagram wakes all `threads` workers (level-triggered, // shared epoll fd) — and since quic_mtx() is one lock per // listener (see socket.h), only one of them can ever do // anything useful; the rest just contend on that mutex for // nothing. ONESHOT + explicit rearm in ConnectEventHandler // limits servicing to one thread at a time, same as TCP/SSL // already do per-connection. if (ss->_Type == sockettype::QUIC) { setevent.events |= EPOLLONESHOT; } // traffic) over this one UDP socket for the connection's whole // lifetime — unlike TCP/SSL, where only accept() shares the // listener and established connections get their own // EPOLLONESHOT'd fd. This used to also be EPOLLONESHOT'd for // QUIC, because quic_mtx() was one lock shared by every // connection on the listener (see socket.h's history) — level // triggered + shared epoll fd meant every datagram woke all // `threads` workers, and only one could ever do anything // useful while the rest contended on that single mutex for // nothing. Each connection now has its own quic_mtx(), and // _child_connections lookups/mutations go through their own // short-lived _registry_mutex (see quic::accept()), so // multiple threads calling accept() concurrently on this fd // now actually process different connections in parallel // instead of serializing on one lock — recvmmsg on the same // fd from multiple threads is safe (the kernel hands out // disjoint datagrams, never duplicates). setevent.data.fd = ss->fd(); if (epoll_ctl(_pollFD, EPOLL_CTL_ADD, ss->fd(), &setevent) < 0) { Loading
src/socket.h +39 −19 Original line number Diff line number Diff line Loading @@ -31,6 +31,7 @@ #include <iostream> #include <atomic> #include <mutex> #include <shared_mutex> #include <string> #include <memory> #include <deque> Loading Loading @@ -996,20 +997,34 @@ namespace netplus { std::map<std::string, ssl::CertificateBundle> _cert_map; ssl::CertificateBundle* _selected_cert = nullptr; // Mutex for thread safety (recursive: musl returns EDEADLK on // double-lock by same thread, which happens when processFrame // callbacks re-enter locking functions like sendStreamData). // Declared before _child_connections/_child_owned_connections // deliberately: members are destroyed in reverse declaration // order, and ~quic() (run for every child when // _child_owned_connections is torn down) locks quic_mtx() — // which for a child resolves to _parent->_quic_mutex. If this // were declared after the child containers (as it used to be), // the parent's _quic_mutex would already be destroyed by the // time its children's destructors ran, locking an already-dead // mutex. // Mutex for this connection's own state (recursive: musl returns // EDEADLK on double-lock by same thread, which happens when // processFrame callbacks re-enter locking functions like // sendStreamData). Each quic object — listener or child — has and // uses its own; unlike the old design, a child's quic_mtx() no // longer delegates to the parent's, so different connections on // the same listener can be processed by different threads at the // same time (see _registry_mutex below for what still needs a // shared lock, and epoll.cpp for the EPOLLONESHOT removal this // enables). Declared before _child_connections deliberately: // members are destroyed in reverse declaration order, and // ~quic() (run for every child when _child_connections' last // shared_ptr reference to it drops) locks quic_mtx() on itself — // harmless now that it's not shared with the parent, but keeping // the ordering avoids relying on that distinction. mutable std::recursive_mutex _quic_mutex; // Guards _child_connections only (registry membership), never a // connection's own state — that's quic_mtx()'s job. shared_mutex // because lookups (every packet routed to an existing connection) // vastly outnumber inserts/erases (new/closed connections): many // threads can look up different — or the same — connections // concurrently via a shared_lock, and only take the exclusive // unique_lock for the rare registry mutation. Declared before // _child_connections for the same destruction-order reason as // _quic_mutex above. mutable std::shared_mutex _registry_mutex; // Connection tracking for server mode (maps DCID to child quic*). // Keyed by the raw CID bytes as a std::string (see cidKey() in // quic.cpp) rather than std::vector<uint8_t>: same correctness Loading @@ -1018,10 +1033,15 @@ namespace netplus { // byte-wise tree comparisons of the old std::map, and a CID that // fits in std::string's small-string buffer needs no heap // allocation for the lookup, unlike a std::vector key. // _child_connections maps DCID to raw pointer for fast lookup // _child_owned_connections vector owns the lifetime via unique_ptr std::unordered_map<std::string, quic*> _child_connections; std::vector<std::unique_ptr<quic>> _child_owned_connections; // Owns child connection lifetimes // Holds shared_ptr rather than a raw pointer + separate owning // vector: each connection is registered under two keys (original // DCID and our local CID) that share ownership of the same // object, and a caller that finds an entry can copy the // shared_ptr out (a "pin") before releasing _registry_mutex, // keeping the connection alive across the handoff to its own // quic_mtx() even if another thread concurrently erases both // registry entries for it. std::unordered_map<std::string, std::shared_ptr<quic>> _child_connections; quic* _parent = nullptr; // For child connections, pointer to parent listening socket // Stream data callback for application layer (quic-specific internal type) Loading Loading @@ -1119,10 +1139,10 @@ namespace netplus { }; std::vector<BatchEntry> _send_batch; // Children delegate to parent's mutex so there is only ONE lock // per QUIC listener — eliminates lock-ordering complexity. // Each connection's own lock — see _quic_mutex's comment for why // this no longer delegates to the parent. std::recursive_mutex& quic_mtx() const { return _parent ? _parent->_quic_mutex : _quic_mutex; return _quic_mutex; } // Friend classes for event handling Loading