Please take race conditions seriously when discussing threading

I wouldn’t fully trust dict.setdefault or any built-in type method for that matter, and I wouldn’t spend time digging into the CPython implementation either.

If it were up to me, I’d prefer the program to crash early when a built-in type is accessed in a non-thread-safe way. Alternatively, raising a NonThreadSafe exception might be more appropriate. This might be a suitable solution for:

A user could attempt a non-thread-safe operation and catch a NonThreadSafe exception if they try to access the same data concurrently, rather than relying on implicit thread-safe access.

Off-topic: I don’t see the value in continuing to hide potential data race bugs, as the GIL does. In my opinion, a more explicit approach would be more helpful. Yes, parallelism is hard, but users should become familiar with it.

However, the most effective approach is to follow a well-defined concurrency model and avoid experimenting with custom solutions in production code.

I don’t think that’s quite true, at least of the second example:

Suppose SAFE_DOSAGE is 100 and patient.dosage is currently 90, and then I have two calls to increase_dosage in separate threads, one to increase it by 2 and one by 9. Then, yes, the safety guarantee will hold (dosage will stay under 100), but it’s a race condition whether the dosage will wind up at 92 or 99, which I would consider part of the substantive behavior of the system.

I realize this probably isn’t what you meant and you were focusing on the safe dosage invariant. But I mention it because it kind of reminded me how many of seem to mean slightly different things by “safe”. :slight_smile: I don’t see how this kind of race condition can truly be alleviated under any kind of parallelism model, since one or the other increment has to happen first. It only seems a bit disturbing because in a totally sequential non-parallel model it can’t arise: you can still wind up in a situation where either 92 or 99 would come out, but it would have to depend on some underlying verifiable state like the order of items in a list, not on a race.

I don’t consider the “weak” version of thread safety (“no crashes”) to be of much practical use to most Python programmers. Everyone certainly wants “consistency” (i.e., no missed steps or violated invariances). But this example makes me realize that whatever we wind up with, we do need to be careful to make clear that we can’t get what everyone really wants, which is “my code will do what I expect”, since sometimes that won’t be achievable. :slight_smile:

2 Likes

If that’s a problem to you, why do you have two calls in separate threads? That would be equivalent to this code:

if random.randrange(2):
    increase_dosage(patient, 2)
    increase_dosage(patient, 9)
else:
    increase_dosage(patient, 9)
    increase_dosage(patient, 2)

Thread safety ensures that one of those two is going to happen, but not something else. But if the order of operations matters, there’s no reason to use threads. I don’t know what your use-case is where this is a problem, but threading is probably the wrong solution.

Because I didn’t know what I was doing and just decided to “add threading” to make my code go faster. :slight_smile:

You’re right, but the thing is that everyone can pretty much see that a call to random like yours involves nondeterminism. But people are much less likely to realize that parallelism can introduce a similar kind of nondeterminism all over the place. All I’m saying is, from the perspective of communicating Python’s behavior to users, regardless of the parallelism model, we need to be careful about saying stuff like “if we do it this way there are no race conditions”, because some race conditions can’t be eliminated and people may still be surprised by that.

3 Likes

I don’t think we can make that happen now. Pretty sure the only way to achieve “my code will do what I expect” is to never run it.

This is not solvable, sorry :slight_smile: You can’t make code go faster with threads unless it is already parallelizable. That is to say, you have to have subparts in the problem that are independent of each other, and can be interleaved. Once you have that, you can think about different ways to parallelize - whether that’s asyncio, which still only runs one at a time, but can interleave; or threads, which run multiple simultaneously so long as they can release the GIL; or processes, which can run simultaneously but need more effort to communicate.

2 Likes

I know that! :slight_smile: Like I said, my point is simply that if we say “X is thread-safe” or “X has no race conditions”, people are going to do stuff like that and say they thought it would be okay. So it behooves us to be circumspect in how we describe any parallelism model for Python.

2 Likes

Maybe, but the right place for “parallelism 101” isn’t every single piece of documentation. We can’t be explaining the fundamentals everywhere.

People who don’t understand parallelism can simply ignore it. If you don’t use threads, your code will execute in sequence, and everything WILL be as you expect. Thread-safety only becomes relevant when you explicitly choose to start using threads.

2 Likes

In my mind, this is really a key point. Although laden with caveats, for the most part, most Python users haven’t really had to even think about what is or isn’t thread safe[1]. You could still get surprised now and then, and that for sure has led to some long head-scratching debugging sessions, but those were pretty rare events.

Is dict.setdefault() thread-safe? Maybe, maybe not, who knows, but I don’t care never cared before. I’m cautiously optimistic that with free-threaded Python, most Python users still won’t care, but I do think we have to be honest that there’s really no way to know for sure until we get a lot more experience with it[2].

I think the technical challenge of making free-threaded Python work has largely been solved. When Larry did the gilectomy, I remember[3] he said something like, of the three things 1) no GIL; 2) fast; 3) backwards compatible, you could have 2 out of 3 but not all of them. Getting rid of the GIL isn’t actually the hard part, and it’s been done several times before now, but Sam and team, plus the Faster CPython team and everybody else who contributes, have probably, finally, this time cracked that nut to give us all three things we really want.

What we haven’t solved is the social and educational sides, and those are becoming the most important next steps. Maybe we’ll need to document which APIs in the stdlib are thread-safe and which aren’t. Maybe we’ll need new and better tools to identify parallelism problems (e.g. TSAN for Python), and help programmers not only ensure their code is thread-safe but that the code communicates that to readers of their code with the same elegance as other aspects of their Python code (e.g. via primitives/functions/built-ins for fast locking or atomicity, etc.). We’ll definitely need more thorough and extensive HOWTOs. We’ll need to internalize and socialize the best practices for safely using free-threaded Python in the incredibly broad and diverse domains Python is used for now, and in the future. And we’ll probably have to actively dispel misinformation about free threaded Python[4].

As a community, our work is cut out for us, but to me personally, the path forward seems clear.


  1. unless you were doing obviously parallel stuff ↩︎

  2. I wonder if we’re not just trading GIL FUD for NOGIL FUD? ↩︎

  3. perhaps incorrectly ↩︎

  4. there’s that FUD trade-off again ↩︎

17 Likes

I agree, although I don’t think there’s any maybe about that one. :slight_smile: I don’t see how free-threading will get uptake unless there’s significantly more detail in the docs about what guarantees are made (or not made) by the builtin types and stdlib.[1]


  1. I say “what guarantees” because these discussions have made it clear that it’s not a matter of “thread-safe” vs. “not thread-safe”, it’s really "what results can be expected under what conditions. ↩︎

Do you mean this change?

As my understanding:

  • Attribute access won’t release GIL
    • Even if it is property.
    • But property implementation may release GIL (by another call or loop).
  • DECREF previous value may call __del__ and __del__ may release GIL.
  • LOAD_GLOBAL won’t release GIL.
  • < won’t release GIL, unless user defined compareter method is used too.

So this code is thread-safe unless a user-defined special method is called. Correct me if wrong.

When teaching Python to others, I never explain when GIL can be take over. It is not Python language spec, but just a implementation detail of current CPython version.
But I agree that free-threading would make this type of thread unsafe real problem, not a theoretical one.

3 Likes

Actually no… and this is the core of the problem !
It is safe under [EDIT:sync mode within] the GIL only.
The thread-safe version should look this way :

def increase_dosage(patient, amount):
    with patient.lock:  # threading.Lock() instance
        if patient.dosage + amount < SAFE_DOSAGE:
            patient.dosage += amount

Sorry, i don’t understand how this code:

def increase_dosage(patient, amount):
    if patient.dosage + amount < SAFE_DOSAGE:
        patient.dosage += amount

is thread-safe using threads with the GIL enabled, and not thread-safe using threads with the free-threading.
What ensures no interruption can happen between the test and the assignment?

(My last post was a bit inaccurate, I edited it.)
The GIL will ensure that test1 or test2 are not made at the same time than write1 or write2, but it does not prevent this pattern : test#, test#, write#, write# (replace # with 1 or 2)

1 Like

The path forward for free threading, certainly.

But for the wider question of parallelism in general, I think there are still significant questions around where we should focus our energy.

Until now, this hasn’t been an important question, because we’ve had a huge investment of resources donated, specifically targeted at implementing free threading. It would have been foolish not to take advantage of that (and to continue to do so, for as long as it’s available). But beyond that, we need to decide how much social pressure we want to put on core developers, library authors, and the community in general, to accept the idea that free threading is the “official” solution.

We’ve seen this already in calls for core devs to get behind free threading, and start making libraries they work on thread safe. But what about core devs who prefer the multiple interpreter model, and prefer not to add cost in the form of explicit locks to libraries they support? Is that no longer an acceptable position to take?

It’s great that the technical issues around free threading have been solved. But we’ve barely scratched the surface of the social and design issues, and it’s far from clear (to me, at least) that free threading is the right model for the average user.

9 Likes

dict.setdefault() involves multiple operations. Even if the entire method were atomic, it still doesn’t align well with my mental model.

When multiple threads call it concurrently without synchronization, it leads to a race condition because you can’t predict which thread’s default value will be set first.

A race condition isn’t always harmful, but the key point is that it still exists.


The quote below recommends using explicit synchronization primitives like threading.Lock rather than relying on the internal locks of built-in types. I fully agree this approach should always be followed when accessing shared resources:

Note: It’s recommended to use the threading.Lock or other synchronization primitives instead of relying on the internal locks of built-in types, when possible. [emphasis added]

But it’s important to emphasize to users that just because two operations are individually thread-safe, it doesn’t mean they remain thread-safe when combined. This is a classic example of the fallacy of composition.

Also, the GIL-based concurrency model, if it can be called that, is not a general concurrency solution and does not apply in a free-threading environment.

1 Like

to? … concurrent/parallel programming? Python today has multiple “official” solutions which aren’t going away.

No disagreement there. We do have a duty to help users navigate the options available to them and help them decide which is best suited to the problem they’re trying to solve. Asyncio, subinterpreters, multiprocessing, OS threads are all still fine to use, depending on what you’re trying to do.

I have used it like this:

# Maybe use WeakValueDictionary:
_cache = {}

class Interned:

    arg: int

    def __new__(cls, arg: int):
        try:
            return _cache[arg]
        except KeyError:
            obj = super().__new__(cls)
            obj.arg = arg
            return _cache.setdefault(arg, obj)

i1 = Interned(2)
i2 = Interned(2)

assert i1 is i2
assert i1 == i2
assert hash(i1) == hash(i2)

I am not intentionally using threads myself but I am aware that someone else might use Interned in multithreaded code. There is a quite likely race condition that two threads might simultaneously call Interned(2) and momentarily two equivalent instances of obj could exist in memory as local variables. I want setdefault to choose exactly one of them and return the same one in both threads which it will if it is atomic.

2 Likes

dict.setdefault is threadsafe and consistent, but can still be used in a manner that is not race-free.

I have a caching decorator that looks like this in the internals

        @wraps(coro)
        async def wrapped(*args: P.args, **kwargs: P.kwargs) -> R:
            key = key_func(args, kwargs)

            ours: cf.Future[R] = cf.Future()
            cached = internal_cache.setdefault(key, ours)

            if cached is not ours:
                return await asyncio.wrap_future(cached)

            cb = partial(_internal_cache_evict, key)
            ours.add_done_callback(cb)

            c = coro(*args, **kwargs)
            a_fut = asyncio.ensure_future(c)
            internal_taskset.add(a_fut)
            a_fut.add_done_callback(internal_taskset.discard)
            a_fut.add_done_callback(partial(_chain_fut, ours))

            return await a_fut

Because this is preemptive caching via future objects, I really don’t care which is chosen so long as all threads choose the same one.

A better option is to use a binning function to distribute work across threads so that there is never any overlap, but this is meant for public consumption, and I know that people don’t do that currently.

Looks like WeakValueDictionary.setdefault is not atomic though:

I guess to be atomic it should depend on dict.setdefault being atomic. Something like:

def setdefault(self, key, default=None):
    try:
        return self.data[key]()
    except KeyError:
        keyed_ref = KeyedRef(default, self._remove, key)
        new_ref = self.data.setdefault(key, keyed_ref)
        if new_ref is keyed_ref:
            return default
        else:
            return new_ref()