From: David Gibson <david@gibson.dropbear.id.au>
To: Laurent Vivier <lvivier@redhat.com>
Cc: passt-dev@passt.top
Subject: Re: [PATCH v2 09/10] flow: Add locking, per-qpair filtering, and intermediate state handling
Date: Mon, 3 Aug 2026 19:58:59 +1000 [thread overview]
Message-ID: <anBmU276zLJlAC-h@zatzit> (raw)
In-Reply-To: <20260731162329.3552800-10-lvivier@redhat.com>
[-- Attachment #1: Type: text/plain, Size: 12356 bytes --]
On Fri, Jul 31, 2026 at 06:23:28PM +0200, Laurent Vivier wrote:
> Protect flow table shared state (free list, hash table) with a
> pthread_rwlock_t. Make flow_new_entry _Thread_local.
>
> Filter flow_defer_handler() by qpair so each thread only processes
> its own flows.
>
> Hold the write lock during flow_defer_handler()'s second pass
> (free-list rebuild), since the free list is shared with flow_alloc().
>
> Since the lock is released between flow_alloc() and FLOW_ACTIVATE(),
> other threads can observe intermediate flow states (NEW, INI, TGT,
> TYPED) during traversal. Adapt flow_foreach() to skip them silently
> instead of logging an error, and change flow_defer_handler()'s
> free-list rebuild to break cluster merging across them instead of
> asserting.
Oof, unfortunately, I'm not sure this is quite enough. The free list
management was bespoke designed for our single threaded use case, and
I'm not sure the current approach quite works any more with
multithreading. More below.
>
> Signed-off-by: Laurent Vivier <lvivier@redhat.com>
> ---
> flow.c | 100 ++++++++++++++++++++++++++++++++++++++++++++-------
> flow_table.h | 2 +-
> 2 files changed, 88 insertions(+), 14 deletions(-)
>
> diff --git a/flow.c b/flow.c
> index 59963ea5b1c2..8deca3e3c7f1 100644
> --- a/flow.c
> +++ b/flow.c
> @@ -12,6 +12,8 @@
> #include <sched.h>
> #include <string.h>
>
> +#include <pthread.h>
> +
> #include "util.h"
> #include "ip.h"
> #include "passt.h"
> @@ -77,7 +79,10 @@ static_assert(ARRAY_SIZE(flow_epoll) == FLOW_NUM_TYPES,
> /* Global Flow Table */
>
> /**
> - * DOC: Theory of Operation - allocating and freeing flow entries
> + * DOC: Theory of Operation
> + *
> + * Allocating and freeing flow entries
> + * ===================================
> *
> * Flows are entries in flowtab[]. We need to routinely scan the whole table to
> * perform deferred bookkeeping tasks on active entries, and sparse empty slots
> @@ -125,11 +130,43 @@ static_assert(ARRAY_SIZE(flow_epoll) == FLOW_NUM_TYPES,
> * when we encounter the start of a free cluster, we can immediately skip
> * past it, meaning that in practice we only need (number of active
> * connections) + (number of free clusters) iterations.
> + *
> + * Flow table locking
> + * ==================
> + *
> + * The flow table has three pieces of shared global state: the free cluster
> + * list (flow_first_free, flowtab[].free), the hash table (flow_hashtab[]),
> + * and the in-progress allocation pointer (flow_new_entry).
> + *
> + * A pthread_rwlock_t (flow_lock) protects the free list and hash table:
> + *
> + * - flow_alloc() and flow_alloc_cancel() take the write lock to modify
> + * the free list.
> + *
> + * - flow_hash_insert() and flow_hash_remove() take the write lock to
> + * modify the hash table.
> + *
> + * - flowside_lookup() takes the read lock to traverse the hash table.
> + *
> + * flow_new_entry is _Thread_local, so each worker thread independently
> + * tracks its own in-progress flow allocation.
> + *
> + * Between flow_alloc() and FLOW_ACTIVATE() (or flow_alloc_cancel()), the
> + * flow is in an intermediate state (NEW, INI, TGT, or TYPED). Other
> + * threads may observe these states during flow table traversal.
> + * flow_foreach() skips them silently, and the free-list rebuild in
> + * flow_defer_handler() breaks cluster merging across them.
> + *
> + * The write lock is held in flow_defer_handler() only for the second
> + * pass (free-list rebuild), which modifies the global free list shared
> + * with flow_alloc(). The first pass (deferred protocol handlers) runs
> + * without the lock and filters by qpair, so each thread only processes
> + * its own flows.
The way free cluster consolidation is done is because it was near-free
to do so, because we were already doing a regular linear scan through
the entire table. Now that each defer handler is only handling a
subset of the entries it's not so clear that this approach makes sense
any more. At the very least with free consolidation passes from every
thread, the effective cost might be significantly higher.
> */
>
> unsigned flow_first_free;
> union flow flowtab[FLOW_MAX];
> -static const union flow *flow_new_entry; /* = NULL */
> +static _Thread_local const union flow *flow_new_entry; /* = NULL */
> int qpair_to_fd[FLOW_QPAIR_SIZE];
>
> /* Hash table to index it */
> @@ -142,6 +179,8 @@ static flow_sidx_t flow_hashtab[FLOW_HASH_SIZE];
> static_assert(ARRAY_SIZE(flow_hashtab) >= 2 * FLOW_MAX,
> "Safe linear probing requires hash table with more entries than the number of sides in the flow table");
>
> +static pthread_rwlock_t flow_lock = PTHREAD_RWLOCK_INITIALIZER;
> +
> /** flowside_from_af() - Initialise flowside from addresses
> * @side: flowside to initialise
> * @af: Address family (AF_INET or AF_INET6)
> @@ -592,12 +631,18 @@ void flow_activate(struct flow_common *f)
> */
> union flow *flow_alloc(unsigned int qpair)
> {
> - union flow *flow = &flowtab[flow_first_free];
> + union flow *flow;
> +
> + pthread_rwlock_wrlock(&flow_lock);
> +
> + flow = &flowtab[flow_first_free];
>
> assert(!flow_new_entry);
>
> - if (flow_first_free >= FLOW_MAX)
> + if (flow_first_free >= FLOW_MAX) {
> + pthread_rwlock_unlock(&flow_lock);
> return NULL;
> + }
>
> assert(flow->f.state == FLOW_STATE_FREE);
> assert(flow->f.type == FLOW_TYPE_NONE);
> @@ -627,6 +672,8 @@ union flow *flow_alloc(unsigned int qpair)
> flow_setqp(&flow->f, qpair);
> flow_set_state(&flow->f, FLOW_STATE_NEW);
>
> + pthread_rwlock_unlock(&flow_lock);
> +
> return flow;
> }
>
> @@ -638,6 +685,8 @@ union flow *flow_alloc(unsigned int qpair)
> */
> void flow_alloc_cancel(union flow *flow)
> {
> + pthread_rwlock_wrlock(&flow_lock);
> +
> assert(flow_new_entry == flow);
> assert(flow->f.state == FLOW_STATE_NEW ||
> flow->f.state == FLOW_STATE_INI ||
> @@ -655,6 +704,8 @@ void flow_alloc_cancel(union flow *flow)
> flow->free.next = flow_first_free;
> flow_first_free = FLOW_IDX(flow);
Alas, this is not safe. Restoring flow_first_free to the flow index
relied on the fact that when single threaded, there could be no other
entries allocated between flow_alloc() and FLOW_ACTIVATE(). Because
we always allocated the first free entry, if we cancel it will become
the first free entry again.
But with the lock dropped between flow_alloc() and
flow_alloc_cancel(), this could be restoring an out of date snapshot
of flow_first_free - it might no longer be the actual first free entry
any more if another thread freed something earlier in the table.
Honestly, I'm not sure if flow_alloc_cancel() makes sense any more in
a multi-threaded context. The rules about when it was and wasn't ok
were already pretty confusing, so maybe we should go through the full
flow close path even for flows cancelled very early.
> flow_new_entry = NULL;
> +
> + pthread_rwlock_unlock(&flow_lock);
> }
>
> /**
> @@ -739,10 +790,15 @@ static inline unsigned flow_hash_probe(const struct ctx *c, flow_sidx_t sidx)
> */
> uint64_t flow_hash_insert(const struct ctx *c, flow_sidx_t sidx)
> {
> - uint64_t hash = flow_sidx_hash(c, sidx);
It might not be necessary to move this under the lock. This reads the
contents of the individual flow (which IIUC is not covered by the
flow_lock), but doesn't depend on any of the shared state data.
> - unsigned b = flow_hash_probe_(hash, sidx);
> + uint64_t hash;
> + unsigned b;
>
> + pthread_rwlock_wrlock(&flow_lock);
> + hash = flow_sidx_hash(c, sidx);
> + b = flow_hash_probe_(hash, sidx);
> flow_hashtab[b] = sidx;
> + pthread_rwlock_unlock(&flow_lock);
> +
> flow_dbg(flow_at_sidx(sidx), "Side %u hash table insert: bucket: %u",
> sidx.sidei, b);
>
> @@ -756,10 +812,15 @@ uint64_t flow_hash_insert(const struct ctx *c, flow_sidx_t sidx)
> */
> void flow_hash_remove(const struct ctx *c, flow_sidx_t sidx)
> {
> - unsigned b = flow_hash_probe(c, sidx), s;
As with insert it might be worth using flow_sidx_hash() and
flow_hash_probe_() separately here, so that the flow_sidx_hash() can
move out of the critical section.
> + unsigned b, s;
> +
> + pthread_rwlock_wrlock(&flow_lock);
> + b = flow_hash_probe(c, sidx);
>
> - if (!flow_sidx_valid(flow_hashtab[b]))
> + if (!flow_sidx_valid(flow_hashtab[b])) {
> + pthread_rwlock_unlock(&flow_lock);
> return; /* Redundant remove */
> + }
>
> flow_dbg(flow_at_sidx(sidx), "Side %u hash table remove: bucket: %u",
> sidx.sidei, b);
> @@ -779,6 +840,7 @@ void flow_hash_remove(const struct ctx *c, flow_sidx_t sidx)
> }
>
> flow_hashtab[b] = FLOW_SIDX_NONE;
> + pthread_rwlock_unlock(&flow_lock);
> }
>
> /**
> @@ -793,10 +855,12 @@ void flow_hash_remove(const struct ctx *c, flow_sidx_t sidx)
> static flow_sidx_t flowside_lookup(const struct ctx *c, uint8_t proto,
> uint8_t pif, const struct flowside *side)
> {
> - flow_sidx_t sidx;
> + flow_sidx_t sidx, ret;
> union flow *flow;
> unsigned b;
>
> + pthread_rwlock_rdlock(&flow_lock);
> +
> b = flow_hash(c, proto, pif, side) % FLOW_HASH_SIZE;
As above, I think the hash can go outside the lock.
> while ((sidx = flow_hashtab[b], flow = flow_at_sidx(sidx)) &&
> !(FLOW_PROTO(&flow->f) == proto &&
> @@ -804,7 +868,11 @@ static flow_sidx_t flowside_lookup(const struct ctx *c, uint8_t proto,
> flowside_eq(&flow->f.side[sidx.sidei], side)))
> b = mod_sub(b, 1, FLOW_HASH_SIZE);
>
> - return flow_hashtab[b];
> + ret = flow_hashtab[b];
> +
> + pthread_rwlock_unlock(&flow_lock);
> +
> + return ret;
> }
>
> /**
> @@ -879,8 +947,8 @@ void flow_defer_handler(const struct ctx *c, const struct timespec *now,
> struct timespec *timer_run, unsigned int qpair)
> {
> struct flow_free_cluster *free_head = NULL;
> - unsigned *last_next = &flow_first_free;
> bool to_free[FLOW_MAX] = { 0 };
> + unsigned *last_next;
> bool timer = false;
> union flow *flow;
>
> @@ -897,6 +965,9 @@ void flow_defer_handler(const struct ctx *c, const struct timespec *now,
> flow_foreach(flow) {
> bool closed = false;
>
> + if (flow->f.qpair != qpair)
> + continue;
> +
> switch (flow->f.type) {
> case FLOW_TYPE_NONE:
> assert(false);
> @@ -928,6 +999,8 @@ void flow_defer_handler(const struct ctx *c, const struct timespec *now,
> }
>
> /* Second step: actually free the flows */
> + pthread_rwlock_wrlock(&flow_lock);
> + last_next = &flow_first_free;
> flow_foreach_slot(flow) {
> switch (flow->f.state) {
> case FLOW_STATE_FREE: {
> @@ -956,8 +1029,8 @@ void flow_defer_handler(const struct ctx *c, const struct timespec *now,
> case FLOW_STATE_INI:
> case FLOW_STATE_TGT:
> case FLOW_STATE_TYPED:
> - /* Incomplete flow at end of cycle */
> - assert(false);
> + /* In-progress allocation on another thread */
> + free_head = NULL;
I think these can all now be additional labels on the
FLOW_STATE_ACTIVE case. At which point, this no longer really wants
to be a switch, but rather just an if (state == FLOW_STATE_FREE) else.
> break;
>
> case FLOW_STATE_ACTIVE:
> @@ -989,6 +1062,7 @@ void flow_defer_handler(const struct ctx *c, const struct timespec *now,
> }
>
> *last_next = FLOW_MAX;
> + pthread_rwlock_unlock(&flow_lock);
> }
>
> /**
> diff --git a/flow_table.h b/flow_table.h
> index 3a33eef15f1e..57944b748920 100644
> --- a/flow_table.h
> +++ b/flow_table.h
> @@ -72,7 +72,7 @@ extern union flow flowtab[];
> (flow) += (flow)->free.n - 1; \
> /* NOLINTNEXTLINE(readability-inconsistent-ifelse-braces) */\
> else if ((flow)->f.state != FLOW_STATE_ACTIVE) { \
> - flow_err((flow), "Bad flow state during traversal"); \
> + (void)0; /* Differs from bare continue */ \
> continue; \
> } else
>
> --
> 2.54.0
>
--
David Gibson (he or they) | I'll have my music baroque, and my code
david AT gibson.dropbear.id.au | minimalist, thank you, not the other way
| around.
http://www.ozlabs.org/~dgibson
[-- Attachment #2: signature.asc --]
[-- Type: application/pgp-signature, Size: 833 bytes --]
next prev parent reply other threads:[~2026-08-03 9:59 UTC|newest]
Thread overview: 22+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-07-31 16:23 [PATCH v2 00/10] multithreading: Prepare data structures for concurrent queue pair workers Laurent Vivier
2026-07-31 16:23 ` [PATCH v2 01/10] tap: Convert packet pools to per-queue-pair arrays for multiqueue Laurent Vivier
2026-08-03 5:41 ` David Gibson
2026-07-31 16:23 ` [PATCH v2 02/10] tap: Make L4 sequence pools per-qpair for thread safety Laurent Vivier
2026-08-03 5:43 ` David Gibson
2026-08-03 5:55 ` David Gibson
2026-07-31 16:23 ` [PATCH v2 03/10] tcp: Make static buffers stack-local " Laurent Vivier
2026-08-03 5:59 ` David Gibson
2026-07-31 16:23 ` [PATCH v2 04/10] udp_vu: Make virtqueue " Laurent Vivier
2026-08-03 6:00 ` David Gibson
2026-07-31 16:23 ` [PATCH v2 05/10] flow: Make flow timer per-caller " Laurent Vivier
2026-08-03 6:11 ` David Gibson
2026-08-03 6:35 ` David Gibson
2026-07-31 16:23 ` [PATCH v2 06/10] tcp: Make TCP timer state per-caller and guard global tasks Laurent Vivier
2026-08-03 6:40 ` David Gibson
2026-07-31 16:23 ` [PATCH v2 07/10] tcp: Protect init socket pools with mutex for thread safety Laurent Vivier
2026-08-03 6:53 ` David Gibson
2026-07-31 16:23 ` [PATCH v2 08/10] tcp: Extract tcp_timer_epoll_add() helper Laurent Vivier
2026-08-03 6:54 ` David Gibson
2026-07-31 16:23 ` [PATCH v2 09/10] flow: Add locking, per-qpair filtering, and intermediate state handling Laurent Vivier
2026-08-03 9:58 ` David Gibson [this message]
2026-07-31 16:23 ` [PATCH v2 10/10] flow: Add lazy, lock-free flow migration between queue pairs Laurent Vivier
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=anBmU276zLJlAC-h@zatzit \
--to=david@gibson.dropbear.id.au \
--cc=lvivier@redhat.com \
--cc=passt-dev@passt.top \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
Code repositories for project(s) associated with this public inbox
https://passt.top/passt
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox;
as well as URLs for IMAP folder(s).