ZhengweiZhu opened a new issue, #3463:
URL: https://github.com/apache/brpc/issues/3463
**Describe the bug**
After reviewing and testing the UBRing/UBShmTransport introduced by #3290,
we found several correctness, scalability, portability, performance, and
maintainability issues.
The two shared-memory backends should be considered separately:
- `--ub_shm_type=1`: local POSIX shared memory (IPC)
- `--ub_shm_type=2`: remote shared memory provided by UBS
Some constraints that may be necessary for UBS do not apply to local IPC
shared memory.
### 1. Deleting a timer may block indefinitely
Linux timerfds are created as blocking file descriptors:
```cpp
timerfd_create(CLOCK_MONOTONIC, 0);
```
Both `DeleteTimerInner()` and `DeleteTimerSafe()` execute a blocking read
before closing the fd:
```cpp
uint64_t exp = 0;
read(fd, &exp, sizeof(exp));
close(fd);
```
There is a deterministic problematic path for one-shot timers:
1. `TimerEpoll()` reads and consumes the timerfd expiration.
2. It calls `UnifiedCallback()`.
3. For a non-periodic timer, `UnifiedCallback()` calls `DeleteTimerInner()`.
4. `DeleteTimerInner()` reads the same timerfd again.
5. Since the one-shot timer has already expired and was drained, the second
blocking `read()` may never return.
For periodic timers, deletion may also block until the next expiration. For
example, deleting a heartbeat timer may block for up to the heartbeat interval.
This can stall connection cleanup and delay releasing resources after a peer
disconnects or crashes.
Timer deletion should not require a blocking read. If the custom
implementation is retained, timerfds should at least use `TFD_NONBLOCK |
TFD_CLOEXEC`, and `EAGAIN` should be treated as normal. Removing the fd from
epoll and closing it without draining it may be sufficient.
### 2. The custom timer subsystem should preferably be replaced with brpc's
existing timer implementation
The timer problems are not isolated defects. They are consequences of
introducing a second timer framework based on timerfd/epoll alongside the timer
framework already maintained by brpc.
brpc already provides:
- `bthread::TimerThread`
- `bthread_timer_add()` and `bthread_timer_del()`
- Versioned timer task IDs
- Cancellation semantics that account for a callback already running
- A shared global timer thread
UBRing can schedule one-shot tasks through the global
`bthread::TimerThread`. Periodic heartbeat or connection-check tasks can
reschedule themselves after each callback. Potentially blocking resource
cleanup should be handed off to a bthread or worker; the timer callback should
only validate state and schedule the cleanup work.
Unless UBRing has a concrete timer requirement that the existing
implementation cannot satisfy, maintaining an independent timer subsystem does
not appear justified. Reusing the existing implementation would eliminate most
of the following newly introduced problems.
#### 2.1 Timer context storage scales with `RLIMIT_NOFILE`
`TimerInit()` allocates a context for every possible process fd and
initializes a spinlock for every entry:
```cpp
getrlimit(RLIMIT_NOFILE, &rlim);
g_max_system_fd = (uint32_t)rlim.rlim_cur;
g_timer_fd_ctx_map =
(TimerFdCtx*)malloc(sizeof(TimerFdCtx) * g_max_system_fd);
```
On x86-64, `sizeof(TimerFdCtx)` is approximately 32 bytes:
| `RLIMIT_NOFILE` | Approximate timer table size |
|---:|---:|
| 1,024 | 32 KiB |
| 20,480,000 | 625 MiB |
The allocation and initialization cost depends on the maximum possible fd
count rather than the number of active UBRing timers.
#### 2.2 Using the numeric fd as timer identity introduces an fd-reuse race
Epoll stores only the numeric fd:
```cpp
event.data.fd = timer_fd;
```
The callback context is found through:
```cpp
g_timer_fd_ctx_map[timer_fd]
```
After a timer is removed and closed, Linux may immediately reuse the fd for
another timer. A stale epoll event containing the old fd can then refer to the
new timer context.
Possible consequences include executing a new timer's callback for an old
event, deleting the wrong timer, or accessing callback arguments after their
owning connection has been released.
Changing the container from an fd-indexed array to a map alone is not
sufficient. Stale events need to be rejected with a generation-safe identity.
`bthread::TimerThread` already uses versioned task IDs for this purpose.
#### 2.3 Timer initialization contains a race
`StartTimeEpoll()` creates the timer thread before `TimerInit()` sets:
```cpp
g_timer_module_initialized = 1;
```
The new thread may run immediately and observe the old value:
```cpp
if (g_timer_module_initialized <= 0) {
break;
}
```
In that case, the timer thread exits during initialization. Initialization
and destruction also use non-atomic global state without an explicit
initialization lock.
#### 2.4 The default close-check timer is too frequent
The close-check timer is configured as:
```cpp
DEFINE_int32(ub_event_queue_timer_interval, 100, ...);
uint32_t interval = FLAGS_ub_event_queue_timer_interval * 1000;
```
The resulting default period is 100 microseconds, or 10,000 wakeups per
second for every UBRing connection. Each connection also creates heartbeat and
cleanup timers.
The close transition should preferably be event-driven. If polling is
unavoidable, the default interval should be significantly coarser and its unit
should be documented.
#### 2.5 All timer callbacks execute serially on one pthread
`TimerEpoll()` invokes callbacks inline from the only timer thread. Some
callbacks perform connection cleanup and may wait for `ub_flying_io_timeout`,
currently five seconds.
A slow callback therefore delays heartbeat and cleanup callbacks for all
other UBRing connections. Timer callbacks should schedule potentially blocking
work on an appropriate bthread or worker instead of executing it inline.
### 3. The macOS timerfd emulation is not functionally equivalent
The macOS implementation creates a pipe and returns only its read end:
```cpp
static int timerfd_create_macosx(...) {
int pipefd[2];
pipe(pipefd);
return pipefd[0];
}
```
There are several issues:
- The pipe write end is leaked.
- Nothing writes expiration counters into the pipe.
- `TimerEpoll()` calls `read(timer_fd, ...)` after receiving an
`EVFILT_TIMER` event, so it can block forever on the empty pipe.
- `timerfd_settime_macosx()` is a no-op.
- `EV_SET()` uses `it_value` as the repeating kqueue interval and does not
correctly model `it_interval`.
- Many timers use an initial expiration of one nanosecond, which becomes
zero milliseconds after conversion.
The kqueue path should process the expiration count from `struct
kevent::data` and should not read from a pipe. Reusing brpc's existing timer
implementation would remove the need for this platform-specific emulation.
### 4. The fixed 60-byte payload should be handled differently for IPC and
UBS
The current message format is fixed to one 64-byte cache line:
```cpp
#define UBR_MSG_HEADER_LEN 4
#define UBR_MSG_PAYLOAD_LEN 60
#define UBR_MSG_LEN 64
```
The current send path first assembles the packet in local memory:
```cpp
memcpy(local_msg_space.payload, input, length);
Copy64Byte(remote_data_queue_slot, &local_msg_space);
```
This design should not be applied identically to both backends.
#### 4.1 Local IPC shared memory
For the IPC backend, both processes map the same POSIX shared-memory object.
The sender can write directly into the destination ring slot.
The current staging path introduces an avoidable copy:
```text
IOBuf/iovec
-> local_msg_space
-> shared-memory ring slot
```
It can instead be:
```text
IOBuf/iovec
-> shared-memory ring slot payload
-> publish the slot state
```
The producer should write the payload and metadata first, then publish the
slot with release semantics. The consumer should check the slot state with
acquire semantics before reading the payload.
This removes one local memory copy and allows the IPC backend to use a more
suitable payload size. A 4 KiB or 64 KiB attachment currently has to be split
into approximately 69 or 1,093 chunks respectively.
The IPC payload size could be a startup-only option, with the selected
format included in the handshake. It must not be changed while connections
using that format are alive.
#### 4.2 Remote UBS shared memory
For UBS, the 64-byte unit may be required by the UB transport's indivisible
remote-store semantics. If that hardware/API requirement is confirmed, UBS may
need to continue assembling a complete 64-byte packet locally before publishing
it remotely.
However, this is a backend-specific constraint and should not force the IPC
backend to use the same format.
The non-`LS64` implementation of `Copy64Byte()` currently falls back to
ordinary `memcpy()`:
```cpp
#ifdef LS64
// ST64B
#else
memcpy(dst, src, 64);
#endif
```
An ordinary `memcpy()` does not itself guarantee an indivisible 64-byte
store. The required atomicity and visibility guarantees should therefore be
documented and validated separately for ARM LS64/ST64B, other local
architectures, UBS remote mappings, and POSIX IPC shared memory.
A possible design is to let each backend provide its own payload size, slot
alignment, publication operation, and memory-ordering requirements. The chosen
format or format version should be negotiated during the UBRing handshake.
### 5. There are several code-style and naming issues
The change contains personal debugging remnants, ambiguous flag names, and
terminology copied from the RDMA transport without adapting it to UBRing.
#### 5.1 Personal debugging remnants should be removed
`src/brpc/ubshm_transport.cpp` contains commented debugging logs with a
personal identifier:
```cpp
// LOG(INFO) << "mwj pollin4=" << pollin ...
// LOG(INFO) << "mwj pollin1=" << pollin;
// LOG(INFO) << "mwj pollin2=" << pollin << " mwj_ret=" << mwj_ret;
// LOG(INFO) << "mwj return 0";
```
It also contains:
```cpp
auto mwj_ret = bthread::butex_wait(...);
```
`src/brpc/ubshm/ub_endpoint.cpp` contains:
```cpp
// TODO mwj should polling start after the connection is established?
```
Personal debugging logs should be deleted rather than committed as comments.
The local variable should have a descriptive name such as `wait_rc`. The TODO
should either be resolved or rewritten as a clear actionable comment without an
author identifier.
#### 5.2 Time-related gflags do not document their units
The following flags do not indicate their units in either the flag name or
description:
```cpp
DEFINE_int32(ub_disconnect_timeout, 5, "Ubshm disconnection timeout.");
DEFINE_int32(ub_connect_timeout, 1, "Ubshm connection timeout.");
DEFINE_int32(ub_hb_timer_interval, 5, "Heartbeat timer interval.");
DEFINE_int32(ub_event_queue_timer_interval, 100,
"Interval of the disconnection timer.");
DEFINE_int32(ub_flying_io_timeout, 5,
"Waiting time for stopping data sending and receiving...");
```
The code uses different units:
- `ub_disconnect_timeout`: seconds
- `ub_connect_timeout`: seconds
- `ub_hb_timer_interval`: seconds
- `ub_event_queue_timer_interval`: microseconds
- `ub_flying_io_timeout`: seconds
A user cannot determine these units from `--help`. The unit should be
encoded in the flag name and description, for example:
```text
ub_disconnect_timeout_s
ub_connect_timeout_s
ub_hb_timer_interval_s
ub_event_queue_timer_interval_us
ub_flying_io_timeout_s
```
If renaming is considered incompatible, the descriptions must at least state
the exact units.
The conversion should also use an existing named constant or time utility
instead of:
```cpp
#define TIME_COVERSION 1000
```
`TIME_COVERSION` is misspelled and does not explain what is being converted.
The existing `USEC_TO_NSEC` constant or a standard time conversion helper would
be clearer.
#### 5.3 `_cq_sid` is RDMA terminology and is inappropriate for UBRing
`UBShmEndpoint` uses the following names:
```cpp
SocketId _cq_sid;
struct CqSidOp;
std::unordered_set<CqSidOp, ...> cq_sids;
```
It also logs:
```cpp
PLOG(WARNING) << "Fail to create socket for cq";
```
These names appear to have been copied from `RdmaEndpoint`. In RDMA, `CQ`
means Completion Queue, so `_cq_sid` describes a socket associated with an RDMA
completion queue. UBRing does not have an RDMA completion queue.
In `UBShmEndpoint`, this synthetic `SocketId` represents an entry registered
with the UBRing poller. Names should describe that role, for example
`_poller_sid`, `PollerSidOp`, and `poller_sids`, or equivalent event-source
terminology.
Copying the RDMA polling structure may be reasonable, but RDMA-specific
terminology should not be retained when the underlying concept is different.
### 6. Current tests do not cover lifecycle and data-path cases
`test/brpc_ubring_unittest.cpp` currently covers hello-message
serialization, endpoint construction, basic IPC allocation, and reset behavior.
It does not cover:
- End-to-end UBRing send and receive
- Large attachments spanning many chunks
- Client or server crash/disconnect cleanup
- One-shot timer expiration and deletion
- Concurrent timer deletion and callback execution
- Numeric fd reuse with stale epoll events
- High `RLIMIT_NOFILE`
- Multiple concurrent UBRing connections
- macOS timer behavior
- Different IPC/UBS message formats
- Validation of time-related gflag units and default intervals
### 7. Bazel and CI support was incomplete, but has been fixed
The original UBRing change documented:
```bash
bazel build --define=with_ubring=true
```
but the Bazel build did not define `BRPC_WITH_UBRING`, did not expose the
performance example targets, and the Bazel CI jobs did not build or run
UBRing-enabled unit tests.
This has already been fixed by #3445, which added:
- `--define=BRPC_WITH_UBRING=true`
- `--config=ubring`
- Bazel targets for the UBRing performance client and server
- UBRing-enabled Bazel compilation and unit-test jobs
- Updated English and Chinese documentation
No further action is requested for this resolved build issue. It is listed
here for completeness because it prevented the original implementation from
being exercised by Bazel CI.
**To Reproduce**
#### Cleanup hang
Build brpc and the performance example:
```bash
bazel build --config=ubring \
//example:ubring_performance_server \
//example:ubring_performance_client
```
Start the IPC server:
```bash
./bazel-bin/example/ubring_performance_server \
--use_ubring=true \
--ub_shm_type=1 \
--port=8002
```
Run a client and let it exit or terminate it:
```bash
./bazel-bin/example/ubring_performance_client \
--use_ubring=true \
--ub_shm_type=1 \
--servers=127.0.0.1:8002 \
--thread_num=1 \
--queue_depth=1 \
--attachment_size=4096 \
--test_seconds=30
```
Inspect a process that becomes stuck during cleanup. The stack can stop in
the blocking `read()` called by `DeleteTimerInner()` or `DeleteTimerSafe()`.
#### Timer table allocation
```bash
ulimit -n 20480000
```
Initialize the UBRing timer module and inspect startup time and RSS.
`TimerInit()` allocates and initializes approximately 625 MiB of timer contexts
even when no UBRing connection exists.
#### IPC chunking
Send 4 KiB and 64 KiB attachments through the IPC backend. The current
60-byte payload creates approximately:
```text
4 KiB -> 69 chunks
64 KiB -> 1093 chunks
```
**Expected behavior**
- UBRing should reuse brpc's existing timer framework unless a concrete
unsupported requirement is documented.
- Timer deletion must never block waiting for another expiration.
- Timer callbacks and deletion must be safe when executed concurrently.
- Stale epoll events must not operate on a new timer that reused the same fd.
- Timer bookkeeping should scale with active timers, not `RLIMIT_NOFILE`.
- Timer callbacks should perform lightweight state transitions and dispatch
blocking cleanup elsewhere.
- macOS timers should have behavior equivalent to the Linux implementation.
- IPC and UBS should use backend-appropriate payload and publication
mechanisms.
- IPC should write directly into shared-memory slots and publish them with
explicit release/acquire ordering.
- UBS should retain a 64-byte packet only if required by documented
hardware/API semantics.
- The active message format must be agreed by both peers.
- All time-related gflags should include their units in their names and
descriptions.
- UBRing code should not contain personal debugging markers or RDMA-specific
terminology for unrelated concepts.
- CI tests should exercise the full send/receive and connection lifecycle.
**Versions**
OS: Helix 8.4r, x86-64
Compiler: GCC 8.5.0
brpc: UBRing introduced by `72bf13a3` (#3290); issues still present on
current master
protobuf: Not relevant to these issues
Build: Bazel UBRing support fixed by #3445
**Additional context/screenshots**
We performed local IPC performance tests with 32 MiB data queues. An
experimental version used an 8 KiB payload and wrote directly into the IPC ring
slot.
| Workload | Original 60B format | IPC 8 KiB direct-write |
|---|---:|---:|
| 4 threads, depth 64, 4 KiB attachment | 761 MB/s | 871-905 MB/s |
| 4 threads, depth 64, 64 KiB attachment | 1,869 MB/s | 3,559-3,925 MB/s |
| 1 thread, depth 1, 4 KiB attachment | 102.6 MB/s | 106.4-107.4 MB/s |
These numbers are directional rather than a strict single-variable A/B
comparison because both the chunk payload size and the IPC copy path were
changed. They nevertheless show that the fixed 60-byte format has a significant
cost for larger messages.
The experimental direct-write IPC path was:
```text
IOBuf/iovec
-> destination shared-memory payload
-> write metadata
-> release-store the ready flag
```
The receiver used an acquire-load of the ready flag before consuming the
payload.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]