Skip to content

Commit 96ea7bc

Browse files
Cpp: move DFA write locks from the ATN onto the DFA
DFA construction (addDFAState's state-set dedup and DFAState::setEdge) was serialized by ATN::_stateMutex / ATN::_edgeMutex. But each ParserInterpreter / LexerInterpreter owns its own decisionToDFA, so concurrent interpreters over a shared (read-only) ATN serialized on a single per-ATN lock for DFA writes they do not share — concurrency became a net loss (measured ~0.6x of serial on 4 threads). Move those two write locks onto the DFA itself (dfa::DFA::stateMutex/edgeMutex, heap-allocated so DFA stays movable) and lock the owning DFA in the simulators. ATN::_mutex (lazy nextTokens cache) is unchanged; edge reads stay lock-free. Independent DFAs now use independent locks (concurrent interpreters scale ~2.1x on 4 threads); a DFA shared across threads (generated recognizers' static decisionToDFA) still serializes its own writers, now at per-decision rather than per-ATN granularity. Same locking discipline, finer scope. Verified with a ThreadSanitizer build of the runtime + an 8-thread concurrent parse harness over a shared ATN: no data races. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Christopher Barber <analog.cbarber@gmail.com>
1 parent baa5db5 commit 96ea7bc

7 files changed

Lines changed: 44 additions & 21 deletions

File tree

runtime/Cpp/runtime/src/atn/ATN.h

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,11 @@ namespace atn {
130130
friend class LexerATNSimulator;
131131
friend class ParserATNSimulator;
132132

133+
// Guards the lazy nextTokens cache (ATNState::_nextTokenWithinRule). The
134+
// DFA state/edge write locks formerly here were moved onto the DFA itself
135+
// (see dfa::DFA::stateMutex/edgeMutex) so concurrent parses with independent
136+
// DFAs no longer serialize on a single per-ATN lock.
133137
mutable internal::Mutex _mutex;
134-
mutable internal::SharedMutex _stateMutex;
135-
mutable internal::SharedMutex _edgeMutex;
136138
};
137139

138140
} // namespace atn

runtime/Cpp/runtime/src/atn/LexerATNSimulator.cpp

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ size_t LexerATNSimulator::match(CharStream *input, size_t mode) {
8080
const dfa::DFA &dfa = _decisionToDFA[mode];
8181
dfa::DFAState* s0;
8282
{
83-
SharedLock<SharedMutex> stateLock(atn._stateMutex);
83+
SharedLock<SharedMutex> stateLock(dfa.stateMutex());
8484
s0 = dfa.s0;
8585
}
8686
if (s0 == nullptr) {
@@ -527,7 +527,7 @@ void LexerATNSimulator::addDFAEdge(dfa::DFAState *p, size_t t, dfa::DFAState *q)
527527
return;
528528
}
529529

530-
UniqueLock<SharedMutex> edgeLock(atn._edgeMutex);
530+
UniqueLock<SharedMutex> edgeLock(_decisionToDFA[_mode].edgeMutex());
531531
p->setEdge(t - MIN_DFA_EDGE, MAX_DFA_EDGE - MIN_DFA_EDGE + 1, q); // connect
532532
}
533533

@@ -559,7 +559,7 @@ dfa::DFAState *LexerATNSimulator::addDFAState(ATNConfigSet *configs, bool suppre
559559
dfa::DFA &dfa = _decisionToDFA[_mode];
560560

561561
{
562-
UniqueLock<SharedMutex> stateLock(atn._stateMutex);
562+
UniqueLock<SharedMutex> stateLock(dfa.stateMutex());
563563
auto [existing, inserted] = dfa.states.insert(proposed);
564564
if (!inserted) {
565565
delete proposed;

runtime/Cpp/runtime/src/atn/ParserATNSimulator.cpp

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -127,11 +127,11 @@ size_t ParserATNSimulator::adaptivePredict(TokenStream *input, size_t decision,
127127

128128
dfa::DFAState *s0;
129129
{
130-
SharedLock<SharedMutex> stateLock(atn._stateMutex);
130+
SharedLock<SharedMutex> stateLock(dfa.stateMutex());
131131
if (dfa.isPrecedenceDfa()) {
132132
// the start state for a precedence DFA depends on the current
133133
// parser precedence, and is provided by a DFA method.
134-
SharedLock<SharedMutex> edgeLock(atn._edgeMutex);
134+
SharedLock<SharedMutex> edgeLock(dfa.edgeMutex());
135135
s0 = dfa.getPrecedenceStartState(parser->getPrecedence());
136136
} else {
137137
// the start state for a "regular" DFA is just s0
@@ -143,7 +143,7 @@ size_t ParserATNSimulator::adaptivePredict(TokenStream *input, size_t decision,
143143
auto s0_closure = computeStartState(dfa.atnStartState, &ParserRuleContext::EMPTY, false);
144144
std::unique_ptr<dfa::DFAState> newState;
145145
std::unique_ptr<dfa::DFAState> oldState;
146-
UniqueLock<SharedMutex> stateLock(atn._stateMutex);
146+
UniqueLock<SharedMutex> stateLock(dfa.stateMutex());
147147
dfa::DFAState* ds0 = dfa.s0;
148148
if (dfa.isPrecedenceDfa()) {
149149
/* If this is a precedence DFA, we use applyPrecedenceFilter
@@ -155,7 +155,7 @@ size_t ParserATNSimulator::adaptivePredict(TokenStream *input, size_t decision,
155155
ds0->configs = std::move(s0_closure); // not used for prediction but useful to know start configs anyway
156156
newState = std::make_unique<dfa::DFAState>(applyPrecedenceFilter(ds0->configs.get()));
157157
s0 = addDFAState(dfa, newState.get());
158-
UniqueLock<SharedMutex> edgeLock(atn._edgeMutex);
158+
UniqueLock<SharedMutex> edgeLock(dfa.edgeMutex());
159159
dfa.setPrecedenceStartState(parser->getPrecedence(), s0);
160160
} else {
161161
newState = std::make_unique<dfa::DFAState>(std::move(s0_closure));
@@ -1293,15 +1293,15 @@ dfa::DFAState *ParserATNSimulator::addDFAEdge(dfa::DFA &dfa, dfa::DFAState *from
12931293
}
12941294

12951295
{
1296-
UniqueLock<SharedMutex> stateLock(atn._stateMutex);
1296+
UniqueLock<SharedMutex> stateLock(dfa.stateMutex());
12971297
to = addDFAState(dfa, to); // used existing if possible not incoming
12981298
}
12991299
if (from == nullptr || t < -1 || t > (int)atn.maxTokenType) {
13001300
return to;
13011301
}
13021302

13031303
{
1304-
UniqueLock<SharedMutex> edgeLock(atn._edgeMutex);
1304+
UniqueLock<SharedMutex> edgeLock(dfa.edgeMutex());
13051305
// Edges are indexed by t + 1 so EOF (t == -1) lands in slot 0; the table
13061306
// therefore needs maxTokenType + 2 slots.
13071307
from->setEdge(t + 1, atn.maxTokenType + 2, to); // connect

runtime/Cpp/runtime/src/dfa/DFA.cpp

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,9 @@ DFAState* DFA::getPrecedenceStartState(int precedence) const {
7373
return nullptr;
7474
}
7575

76-
// Read under ATN::_edgeMutex (held by the caller); the precedence start-state
77-
// table is the only edge table that grows, and it is never read via the
78-
// lock-free getEdge path used by the lexer/parser simulators.
76+
// Read under this DFA's edgeMutex() (held by the caller); the precedence
77+
// start-state table is the only edge table that grows, and it is never read
78+
// via the lock-free getEdge path used by the lexer/parser simulators.
7979
return s0->getEdge(static_cast<size_t>(precedence));
8080
}
8181

runtime/Cpp/runtime/src/dfa/DFA.h

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55

66
#pragma once
77

8+
#include <memory>
89
#include <unordered_set>
910
#include <vector>
1011
#include <string>
1112
#include <cstddef>
1213
#include "antlr4-common.h"
1314
#include "dfa/DFAState.h"
15+
#include "internal/Synchronization.h"
1416

1517
namespace antlr4 {
1618
namespace dfa {
@@ -89,12 +91,31 @@ namespace dfa {
8991

9092
std::string toLexerString() const;
9193

94+
/// Locks guarding writes to THIS DFA: stateMutex() serializes state-set
95+
/// insertion (addDFAState), edgeMutex() serializes edge-table allocation and
96+
/// stores (DFAState::setEdge). Lock-free DFAState::getEdge() reads need
97+
/// neither. These live on the DFA — not the ATN — so independent DFAs (each
98+
/// interpreter owns its own decisionToDFA) never serialize against one
99+
/// another; a DFA shared across threads (generated recognizers' static
100+
/// decisionToDFA) still serializes its own writers, as before, but now at
101+
/// per-decision rather than per-ATN granularity.
102+
internal::SharedMutex &stateMutex() const noexcept { return *_stateMutex; }
103+
internal::SharedMutex &edgeMutex() const noexcept { return *_edgeMutex; }
104+
92105
private:
93106
/**
94107
* {@code true} if this DFA is for a precedence decision; otherwise,
95108
* {@code false}. This is the backing field for {@link #isPrecedenceDfa}.
96109
*/
97110
bool _precedenceDfa;
111+
112+
// Heap-allocated so DFA stays movable (SharedMutex is neither movable nor
113+
// copyable). A moved-into DFA gets fresh locks via these initializers; DFAs
114+
// are only moved empty during decisionToDFA construction, before any parse.
115+
std::unique_ptr<internal::SharedMutex> _stateMutex =
116+
std::make_unique<internal::SharedMutex>();
117+
std::unique_ptr<internal::SharedMutex> _edgeMutex =
118+
std::make_unique<internal::SharedMutex>();
98119
};
99120

100121
} // namespace atn

runtime/Cpp/runtime/src/dfa/DFAState.cpp

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@ void DFAState::setEdge(size_t index, size_t minSize, DFAState *target) {
4242
_edgeCount.store(newCount, std::memory_order_release);
4343
_edges.store(newEdges, std::memory_order_release);
4444
// Only the precedence start state ever reaches this with a non-null
45-
// `edges`, and it is read exclusively under ATN::_edgeMutex (never via the
46-
// lock-free getEdge path), so freeing the old table here cannot race a
47-
// concurrent reader.
45+
// `edges`, and it is read exclusively under the owning DFA's edgeMutex()
46+
// (never via the lock-free getEdge path), so freeing the old table here
47+
// cannot race a concurrent reader.
4848
delete[] edges;
4949
edges = newEdges;
5050
}

runtime/Cpp/runtime/src/dfa/DFAState.h

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,8 @@ namespace dfa {
116116
/// {@code edges[symbol]} points to the target state for that symbol. The
117117
/// table is allocated lazily at a fixed size and its slots are published
118118
/// with release/read with acquire, so getEdge() needs no lock. Allocation,
119-
/// growth and stores (setEdge) must be serialized by the caller through
120-
/// ATN::_edgeMutex.
119+
/// growth and stores (setEdge) must be serialized by the caller through the
120+
/// owning DFA's edgeMutex() (dfa::DFA::edgeMutex).
121121

122122
/// Lock-free read of the edge for the given (already offset) index, or
123123
/// nullptr if there is no such edge yet. Safe to call without any lock.
@@ -135,8 +135,8 @@ namespace dfa {
135135
/// Store an edge. `minSize` is the natural full size of the table for this
136136
/// DFA kind (the lexer char range, or maxTokenType+2 for the parser, whose
137137
/// edges are indexed by t+1 so EOF lands in slot 0); the table is
138-
/// grown to at least `index + 1` if needed. The caller MUST hold the
139-
/// ATN::_edgeMutex write lock. Lexer/parser tables are allocated once at
138+
/// grown to at least `index + 1` if needed. The caller MUST hold the owning
139+
/// DFA's edgeMutex() write lock. Lexer/parser tables are allocated once at
140140
/// `minSize` and never reallocated, so concurrent lock-free getEdge() calls
141141
/// never observe a moved table; only the precedence start state grows, and
142142
/// it is read exclusively under the lock.

0 commit comments

Comments
 (0)