Repository navigation
perf: cheaper hot paths in the circuit breakers, AsyncBulkhead and the event-loop guard - #183
Merged
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four independent changes, one commit each. None of them is meant to change behaviour. The one thing a user could notice is that a
KeyedCircuitBreakerkey function no longer runs under the breaker lock (see "Notes for 0.22.0").What changed
_CircuitBreakerStatehas a new attribute,closed_ticket: the epoch while the circuit is CLOSED,Noneotherwise._call_syncreads it once without the lock and takes the lock only when it isNone.record()and every transition still run under the lock._KeyedStates.getreads the circuit map without a lock. On a miss it takes a separate_create_lock, checks again and inserts. The async breakers use the same shape (one read,admit()only onNone), which saves a method call and atryblock per call.(scheme, raw_host, port)tuple instead of an"https://host:port"string built for every request. The string is still what events carry incircuit_key. It is built once, when the circuit is created, with the same expression as before.AsyncBulkheadskipsasyncio.timeoutwhen a slot is free.if self._loop is not asyncio.get_running_loop()) and callcheck_event_looponly on a mismatch. A call used to build two lambdas. The error message constants are unchanged.Concurrency reasoning (please check this part)
Sync breaker, unlocked read of
closed_ticketThe only unlocked read on the sync admission path is
state.closed_ticket, one attribute load. Its only writer is_move_to, which always runs under the breaker lock and sets_state,_epochand thenclosed_ticket. Under the lock,closed_ticket == (_epoch if _state is CLOSED else None)always holds. The sync Hypothesis test asserts this after every step and the stress test after everyrecord.On free-threaded 3.14t a load or store of an object reference in an instance attribute is atomic: a reader sees the old value or the new one, never a mix. The int is immutable, so it is complete when published. Under the GIL this is trivially true.
What the reader can see:
An int
E.Ewas the epoch of a CLOSED era, becauseclosed_ticketonly ever holds an int while the circuit is CLOSED. The call goes ahead with ticketE, which is exactly whatadmit()under the lock would have returned at the moment of the load. That load is the linearization point. Later,record(E, ...)runs under the lock:_epochis stillE, the circuit is still in that CLOSED era. Every transition adds 2 to the epoch and epochs are never reused, so the outcome applies as it always did._epoch > E, soEis neither_epochnor_epoch + 1andrecorddrops the outcome. A call admitted under the lock just before the transition and still in flight across it is treated the same way.A stale read is therefore indistinguishable from a locked admission that happened slightly earlier. The straggler fix (fix: circuit breaker ignores straggler outcomes, unwraps budget exhaustion, rate-limits circuit.rejected #167) already handles that case.
None. The call falls back toadmit()under the lock.admit()readsclosed_ticketagain, because the circuit may have closed in the meantime (test_locked_admission_into_a_circuit_closed_since_the_unlocked_read_gets_the_closed_ticketcovers this), and otherwise runs the OPEN and HALF_OPEN logic exactly as before.Why one packed attribute and not
_stateand_epochread separately: that pair can tear. A reader could see_state is CLOSEDat epochE, then the circuit could go to OPEN and on to HALF_OPEN (E + 4) before the reader loads_epoch. The reader would then hold ticketE + 4, which equals the HALF_OPEN epoch, sorecordwould take it as a CLOSED-era outcome while the circuit is half-open. It would count a failure towardfailure_threshold, or reset the failure count, and go around the probe. One load closes that window.The invariants the task listed:
_probe_in_flightis read and written only insideadmit()andrecord(), both under the lock. The fast path never hands out a probe ticket: probe tickets are odd (epoch + 1) and come only fromadmit().success_thresholdrespected._consecutive_successeschanges only inrecord()for the current probe ticket, under the lock.record()compares the ticket against_epochunder the lock, as before.Keyed lookup without the lock
_KeyedStates.getcallsself._states.get(key)without a lock. On 3.14t, dict reads are safe against a concurrent insert: writes lock the dict, and reads that race a write retry. Under the GIL adict.getis atomic. Inserts happen only in_create, under_create_lock, with a second lookup, so each key gets exactly one state. Entries are never replaced or removed, so a successful unlocked lookup returns the single state that key will ever have. A miss takes the locked path._create_lockis separate from the breaker lock so that creating a circuit does not wait behindrecord()calls on other keys. Keyed transitions still share one breaker-wide lock, as before.Event-loop guard
self._loopgoes fromNoneto a loop once, under_loop_lock, and never changes after that. The inline check reads it without the lock. If it is the running loop we are done. Otherwisecheck_event_loopreads it again under the lock and either binds the running loop (None) or raises. Because every caller now checks first, the helper no longer does its own unlocked read, and its unit test changed to match.AsyncBulkhead fast path
acquire()in CPython 3.11.16 and 3.14.7 (read frominspect.getsource) starts withif not self.locked(): self._value -= 1; return True. It does not suspend, so the timeout has nothing to bound. In both versionslocked()isself._value == 0 or any(not w.cancelled() for w in self._waiters). It is true whenever a live waiter is queued, so the fast path is taken only whenacquire()itself would take it, and a new caller cannot get ahead of a queued waiter. Noawaitruns between ourlocked()call and the one insideacquire(), so the two see the same state.test_a_freed_slot_goes_to_the_queued_waiter_before_a_new_callpins the FIFO order.acquire_timeout=0with a free slot already succeeded before this change, because the timeout was cancelled before it could fire, so that case is unchanged.Numbers
Best-of-7 or best-of-15
timeitwith a trivialnext, several alternating runs, Apple Silicon (8 cores), CPython 3.14.7. The machine was shared with other jobs (load average 3 to 10), so these are ranges across runs.Whole branch against
origin/main, ns per call, three alternating runs:CircuitBreakerKeyedCircuitBreakerAsyncCircuitBreakerAsyncKeyedCircuitBreakerAsyncBulkheadEach change against the commit before it:
CircuitBreaker742 to 851 down to 557 to 639 ns (measured while the machine was busier), about 25% less.KeyedCircuitBreaker1818 to 2391 down to 1564 to 1964 ns. The async admission shape, measured in one process against the old shape, saves 50 to 75 ns (392/490 down to 340/416 ns). Eight threads on 3.14t:CircuitBreaker0.78 to 0.93 up to 1.27 to 1.34 Mcalls/s,KeyedCircuitBreaker0.30 to 0.33 up to 0.49 to 0.55 Mcalls/s.KeyedCircuitBreaker717 to 950 down to 513 to 633 ns,AsyncKeyedCircuitBreaker1078 to 1445 down to 852 to 1174 ns.AsyncCircuitBreaker626 to 786 down to 390 to 409 ns,AsyncKeyedCircuitBreaker940 to 1021 down to 654 to 701 ns,AsyncBulkheadabout 300 ns less in two of three runs (one noisy run showed no gain).Thread throughput for the whole branch, 8 threads, Mcalls/s:
KeyedCircuitBreaker, 3.14tCircuitBreaker, 3.14tKeyedCircuitBreaker, 3.14 (GIL)CircuitBreaker, 3.14 (GIL)The plain breaker on 3.14t was too noisy to call. Every call still takes the lock once, in
record(), and that lock is now the bottleneck. I did not reproduce the earlier estimates of about 60% less per sync call and 2.9x keyed throughput on 3.14t. What I measured is about 25 to 35% less per sync call and 1.5x to 2x keyed throughput.Tests
tests/test_circuit_breaker_sync_props.py(new): the sync twin of the async interleaving property. Each call runs on its own thread and blocks innextuntil the test finishes it, so Hypothesis picks the overlap order. After every step it checks that a stale outcome leaves the state alone, that probes never overlap, that HALF_OPEN to CLOSED happens after exactlysuccess_thresholdprobe successes, and thatclosed_ticket,_probe_in_flightand the counters agree.test_lock_free_admission_keeps_the_circuit_invariants(stress): 16 threads, 2000 calls each,reset_timeout=0so the circuit cycles constantly. It wraps the state'sadmitandrecord, which run under the breaker lock, and checks the same invariants there: at most one probe ticket outstanding, no stale ticket changing any state or emitting events, consistent counters, and each half-open era beingsuccess_thresholdsuccesses or a run of successes ending in one failure.test_racing_first_requests_share_one_circuit_per_origin(stress): threads race to create circuits for four origins. Every recorded failure must land on the one shared circuit per origin.circuit_keyfor a custom key is itsstr()(sync and async). There is also the FIFO test for the bulkhead.Mutation checks on 3.14t: dropping the stale-epoch check in
recordfails both the property test and the stress test. Creating keyed circuits without the lock and the second lookup fails the keyed stress test in about one run in three. A torn read of_stateand_epochwas not caught by the stress test, because the window between two loads is a few nanoseconds. That case is covered by the reasoning above, not by a test.just lint,just lint-ciandjust test-cipass: 1275 tests, 100% coverage. The full suite also passes on 3.11 and on 3.14t, and the stress tests passed in repeated runs on 3.14t.tests/test_bulkhead_props.py::test_fail_fast_rejects_when_at_capacityfailed twice in fourtest-ciruns while the machine was heavily loaded, and never failed in later runs. It depends on timing (holders sleep 50 ms while the extras must be rejected) and goes through the locked path, which this branch does not change. I left it alone.Notes for 0.22.0
circuit_keyin events keeps its exact format.keyfunction ofKeyedCircuitBreakernow runs outside the breaker lock, so several threads can call it at once. Pure functions of the request, the documented use, are unaffected.