Signals notify a process or thread that something happened. They are not an arbitrary application-message queue. A signal has a disposition, and each thread has a mask of blocked signals. A blocked selected signal may remain pending until ordinary code consumes it through signalfd or a synchronous waiting API.
Pending does not mean counted
Standard signals can coalesce: two sends while the same signal is pending may still leave one pending signal. Real-time signals have different queueing rules. Do not use a standard signal's arrival count as the number of jobs to process. Store work in a queue and use a notification only to wake its consumer.
#include <cassert>
#include <cstdio>
#include <cstdlib>
#include <fcntl.h>
#include <unistd.h>
#include <cerrno>
#include <cstdint>
#include <sys/eventfd.h>
int main() {
int fd=::eventfd(0,EFD_NONBLOCK|EFD_CLOEXEC);assert(fd>=0);std::uint64_t a=2,b=5,value=0;
assert(::write(fd,&a,8)==8);assert(::write(fd,&b,8)==8);assert(::read(fd,&value,8)==8);
auto n=::read(fd,&a,8);std::printf("%llu %d\n",static_cast<unsigned long long>(value),n==-1&&errno==EAGAIN);::close(fd);
}The first runnable example instead uses eventfd, whose uint64 counter adds notification values. Without EFD_SEMAPHORE, one read returns the sum and resets the counter. With that flag, reads have a different one-at-a-time contract. An empty nonblocking read reports EAGAIN; writing UINT64_MAX is invalid.
Keep ordinary C++ work outside handlers
An asynchronous handler interrupts whatever code was running. Allocation, formatting, logging and locking may reenter library state or deadlock. A very small handler can use only appropriate async-signal-safe operations, preserving errno if necessary. A signalfd-based application instead consumes records in ordinary event-loop control flow and can handle shutdown alongside I/O readiness. SIGKILL and SIGSTOP cannot be intercepted this way.
Choose signal ownership before starting worker threads: new threads inherit the creator's mask, and signalfd creation alone does not block signals in every thread. A thread-directed signal can only be consumed by the appropriate thread. The build accepts a borrowed, already-configured nonblocking signalfd so it does not silently change its caller's masks or dispositions.
#include <cassert>
#include <cstdio>
#include <cstdlib>
#include <fcntl.h>
#include <unistd.h>
#include <signal.h>
int main() {
sigset_t set,old,pending;::sigemptyset(&set);::sigaddset(&set,SIGUSR1);assert(::sigprocmask(SIG_BLOCK,&set,&old)==0);
assert(::raise(SIGUSR1)==0);assert(::raise(SIGUSR1)==0);assert(::sigpending(&pending)==0);int before=::sigismember(&pending,SIGUSR1);
timespec zero{};assert(::sigtimedwait(&set,nullptr,&zero)==SIGUSR1);
assert(::sigpending(&pending)==0);int after=::sigismember(&pending,SIGUSR1);
while(::sigtimedwait(&set,nullptr,&zero)>=0){} assert(::sigprocmask(SIG_SETMASK,&old,nullptr)==0);
std::printf("%d %d\n",before,after);
}Teardown matters too
If an owned signal is still pending when you restore an unblocked mask, its default action may happen immediately. The test fixture masks only its selected signals, raises them in the current thread, drains remaining records on cleanup, and restores the previous mask. This keeps a failed learner assertion from leaving an unrelated signal-delivery hazard for the next test.
Before the fill, distinguish three states: a positive counter value, an empty counter reporting EAGAIN, and an invalid descriptor reporting EBADF. Then consider two different pending signals, which a one-record drain must not silently leave behind when the caller's limit allows both. Continue to epoll.