Define a concurrency model (aka memory model) for Python

A plain Python object is one that has implemented in Python, doesn’t subclass any builtin classes (other than object) and doesn’t implement __getattribute__/__setattr__.
In other words, “normal” attribute access.

We need to be very specific here. I assume we mean attributes backed by plain old __dict__ and for now exclude attributes implemented via descriptors such as @property and __slots__. (@property maybe difficult or probably next to impossible. But I think __slots__ might be doable?)

Agreed, and even simpler obj.a = 12 will need to decref the former value referenced from obj.a and thus may run a finalizer which being a function call means it can be a premption point. Thus:

obj.a = 12
assert obj.a == 12

may fail. So while assignment may be atomic it is not necessarily preemption free and the very next statement may witness a world which had changed in unexpected ways be another GIL thread running between the two statements.

1 Like

yes, sorry “exact int” was meant to be an example, but was worded poorly. My “model” says that compound operations are only GIL atomic for a small subset of exact builtin types such as numerics.

In terms of FT goal being SC that is great, but unless I’ve missed something the FT impl currently defaults to RA for stores/loads.

Regarding my “simple enough for devs to reason about”. I agree, my framing was from a Java dev used to the Java memory model. I agree that these types of memory modes are still hard to reason about and your PEP-805 is ultimately a better direction for ease of understanding.

Since at least java 8, the requirements for the language have been significantly stronger (more restrictive on implementations) than python ever has. Some of the way this has been discussed in this and parallel discussions has been including assumptions that are already implementation specific, whether that be that “operation” isn’t even something required to be the same across implementations, to that half of the questions posed originally were specifically referencing the GIL, something that is a planned to be phased out implementation detail of one specific implementation.

I get the desire for stronger language, I argue for it myself where I would benefit from it, but I think it’s a disservice to those discussing it to start with a framing that’s incompatible with being about the language and not expressly limit the scope to what the discussion can be compatible with. In this case, if we’re talking about the guarantees provided by the GIL, that’s never going to be language specification, but could be a useful set of documented guarantees for the GIL-enabled build of CPython.

I read the OP as taking a wider view, and using the GIL/free-threading builds as a first step to have something concrete to talk about (and even if the outcome doesn’t become a language guarantee, having those concurrency models be documented would also be valuable just for CPython itself).

2 Likes

In my opinion I don’t think there’ll ever been a single memory model for the Python language beyond mentioning it as implementation-defined behavior, and that “each implementation that supports multithreading shall document a memory model (or memory models as appropriate) :

  • What built-in objects and operations give what kind of concurrency behavior (atomic or otherwise)
  • What kind of memory ordering (sequential consistency / release-acquire etc.) is provided by the implementation
  • How garbage collection / reference counting / finalization behaves in that implementation when multithreading is involved, etc.
  • Whether the implementation makes any guarantee on the above behavior.”

The above discussions can very well serve as a starting point for CPython-GIL and CPython-FT. Other implementations shall be free to choose whether and how closely to follow CPython-(GIL/FT).

I’d go at list a bit further. The language could prescribe a basic model which can be relied upon across implementations. This basic model would unfortunately likely need to be quite weak so as not to force implementations down paths which they don’t fit. Each implementation could then define a stronger model. I’d only do this out of practicality and in fact did do that for the thread-sanitizer I’m working on.

Aspect weak
Ordinary accesses No implicit cross-thread ordering or publication guarantees.
Native storage Built-in operations retain their promised structural safety; universal serialization is not assumed. Unreviewed native sharing may still be flagged.
Concurrency Full parallelism is allowed.
Synchronization Releasing a lock happens-before a subsequent successful acquisition of that lock. Other recognized explicit synchronization and thread lifecycle handoffs also establish happens-before.
Compound operations Not implicitly atomic. Lost updates, check-then-act races, inconsistent snapshots, and write skew remain possible.
Data races Unordered read/write and write/write conflicts are reportable; read/read pairs are not.

Off-topic for this thread but since you’re bringing it up: I’d love to hear more about this thread sanitizer you’re working on. How do you avoid false positives for detecting races?

Hi Nathan,

The thread-sanitizer was started by a grad student last year as part of their master’s thesis, see PyTsan: Automated Data Race Detection in Python Programs . I’ve taken on the work now to change it form something academically interesting into something that can be used for real. My version which I intend to eventually make public adds support for the gil/ft models and a large number of other types of thread-safety risk detections such as deadlocks, check then act bugs, etc. For the most part it is “false positive” free as it flags places where it witnesses the same storage location being accessed across multiple threads without any happens-before relationship. Potential for deadlock is based on witnessing lock inversion over time even if never simultaneously, i.e. we don’t have to deadlock to detect a deadlock risk.

9 Likes

Thank you for linking the thesis, I hadn’t heard about it. This seems really potentially useful! I’m eagerly waiting to hear more about this.

2 Likes

Perhaps I should provide some details from my analysis of the current implementation to help move this discussion forward.

GIL

Obviously, sequential consistency rules here. Rationale:

  1. Release and acquire are, as the name implies, a release operation and an acquire operation (release-acquire ordering) or stronger.
  2. The lock ensures mutual exclusion, and as a result, the release operation synchronizes-with the acquire operation.
  3. Which means we have the happens-before relationship (visible side effects).

Any operations that do not release the GIL can be considered atomic.

FT

The implementation does not maintain sequential consistency. It does not even maintain release-acquire ordering in general. A very clear example is structmember.c (the implementation of descriptors such as user-defined slots):

  • Arbitrary objects (_Py_T_OBJECT/Py_T_OBJECT_EX): PyMember_GetOne() is a sequentially consistent operation (see FT_ATOMIC_LOAD_PTR()), but PyMember_SetOne() is a release operation (see FT_ATOMIC_STORE_PTR_RELEASE()).
  • All others: relaxed operations.

The PyMutex functions used by critical sections (PyMutex_LockFast()/PyMutex_Lock() and PyMutex_Unlock()) maintain sequential consistency (CAS[1] without explicit ordering is seq_cst by default). However, since not all operations are sequentially consistent, we will simply imply release-acquire ordering.

In listobject.c, both PyList_GetItemRef() and PyList_SetItem() use critical sections and maintain release-acquire ordering (see _Py_atomic_load_ptr() in the former’s list_get_item_ref() + FT_ATOMIC_STORE_PTR_RELEASE() in the latter). But PyList_Size() is a relaxed operation.

The same observations apply to other objects (dictionaries, aka globals, __dict__, etc.), from which we conclude:

  • Load operations (slot.__get__(), value = dict[key], and so on) that return arbitrary objects are typically acquire operations or stronger.
  • Store operations (slot.__set__(), dict[key] = value, and so on) that set arbitrary objects are typically release operations or stronger.
  • All other operations (for example, those returning an int instead of arbitrary objects) can be relaxed.

Why release-acquire ordering? In my opinion, the answer is very simple: if we do not maintain release-acquire ordering for arbitrary objects, then load operations will return objects that may be visible to the thread as partially initialized on platforms with a weaker memory model (hi, ARM!). In that case, user code (at the pure-Python level!) would have to use locks for every transfer of a newly created object between threads, and that is a potential performance killer. This is especially true for functions like inspect.markcoroutinefunction(), which may not have any visible effect on other threads under such conditions. Release-acquire ordering, on the other hand, establishes the synchronizes-with relationship, causing the code to behave similarly to x86-TSO (in terms of visible side effects, with the exception of relaxed operations: len(obj)/bool(obj), and so on; note that RA[2] does not imply TSO[3]).

A small thought experiment: in a world without release-acquire ordering, what is the point of thread-safe containers if we still need locks anyway?

Note that, in this sense, the “Data Race in the Accepted Fix of pydoc._start_server()” section of the mentioned thesis is essentially a false positive, since it considers precisely the scenario where release-acquire ordering is not maintained.


By the way, a good illustration of why a load as a sequentially consistent operation and a store as a release operation are still in release-acquire ordering (weaker than sequential consistency) is the mapping of these operations to processors: on x86(-64), load seq_cst and store release are no different from their relaxed equivalents, and only store seq_cst has a fence.

Proof: Compiler Explorer (both operations are simply mov)


  1. compare-and-swap ↩︎

  2. Release-Acquire ↩︎

  3. Total Store Order ↩︎

2 Likes

Read-modify-write operations deserve special mention. A classic example of such an operation is dict.setdefault(key, value), but I also consider slot.__delete__() and del dict[key] as read-modify-write operations, since they read the current value/item, modify it to NULL, and either write it to the slot/dictionary or raise an exception depending on the result of the read (one could say that each of these three is an implicit CAS; the latter two just do not accept arbitrary objects).

As a user, I want to expect that such operations maintain the expected modification order. That is, for example, those three must succeed in no more than the number of preceding store operations: an empty dictionary with no concurrent store operations must set only one object and return the same object in all threads, and a slot must be able to be deleted only once under the same conditions (this behavior is achieved in 3.14.5; though I found it strange that the incorrect behavior was mentioned as “a sequential consistency bug”, since it is not directly related to order).

Why are they worth mentioning? Because they should not be confused with plain load/store operations. A side effect of store operations is that they merely set a value; therefore, when used with ordering weaker than sequential consistency, they may result in inconsistency with load operations. Read-modify-write operations, on the other hand, should be treated as indivisible (the read and write are a single operation, which is why in C/C++11 atomic read-modify-write operations have a consistent side effect even with relaxed ordering), and this is what I would mean by their thread-safety (and I would consider a violation of this expectation to be a bug).