Explain race condition in double checked locking
Asked Answered
S

2

5
void undefined_behaviour_with_double_checked_locking()
{
    if(!resource_ptr)                                    #1
    {
        std::lock_guard<std::mutex> lk(resource_mutex);  #2
        if(!resource_ptr)                                #3
        {
           resource_ptr.reset(new some_resource);        #4
        }
    }
    resource_ptr->do_something();                        #5
}

if a thread sees the pointer written by another thread, it might not see the newly-created instance of some_resource, resulting in the call to do_something() operating on incorrect values. This is an example of the type of race condition defined as a data race by the C++ Standard, and thus specified as undefined behaviour.

Question> I have seen the above explanation for why the code has the double checked locking problem that causes the race condition. However, I still have difficulties to understand what the problem is. Maybe a concrete two-threads step-by-step workflow can help me really understand the race problem for the above the code.

One of the solution mentioned by the book is as follows:

std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag;

void init_resource()
{
    resource_ptr.reset(new some_resource);
}
void foo()
{
    std::call_once(resource_flag,init_resource); #1
    resource_ptr->do_something();
}
#1 This initialization is called exactly once

Any comment is welcome -Thank you

Sought answered 20/12, 2011 at 4:43 Comment(1)
Read the second part of this answer for some non obvious problems.Loo
C
6

The simplest problem scenario is in the case where the intialization of some_resource doesn't depend on resource_ptr. In that case, the compiler is free to assign a value to resource_ptr before it fully constructs some_resource.

For example, if you think of the operation of new some_resource as consisting of two steps:

  • allocate the memory for some_resource
  • initialize some_resource (for this discussion, I'm going to make the simplifying assumption that this initialization can't throw an exception)

Then you can see that the compiler could implement the mutex-protected section of code as:

1. allocate memory for `some_resource`
2. store the pointer to the allocated memory in `resource_ptr`
3. initialize `some_resource`

Now it becomes clear that if another thread executes the function between steps 2 and 3, then resource_ptr->do_something() could be called while some_resource has not been initialized.

Note that it's also possible on some processor architectures for this kind of reordering to occur in hardware unless the proper memory barriers are in place (and such barriers would be implemented by the mutex).

Compellation answered 20/12, 2011 at 10:45 Comment(1)
For "new someresource; ", this is guaranteed by C++ operator new that the resource is initialized before return. This is how operator new always work in C++, right ? How come the step 1/3 separated? Am I missing something?Expert
T
9

In this case (depending on the implementation of .reset and !) there may be a problem when Thread 1 gets part-way through initializing resource_ptr and then gets paused/switched. Thread 2 then comes along, performs the first check, sees that the pointer is not null, and skips the lock/fully-initialized check. It then uses the partially-initialized object (probably resulting in bad things happening). Thread 1 then comes back and finishes initializing, but it's too late.

The reason that a partially-initialized resource_ptr is possible is because the CPU is allowed to reorder instructions (as long as it doesn't change single-thread behaviour). So, while the code looks like it should fully-initialize the object and then assign it to resource_ptr, the optimized assembly code might be doing something quite different, and the CPU is also not guaranteed to run the assembly instructions in the order they are specified in the binary!

The takeaway is that when multiple threads are involved, memory fences (locks) are the only way guarantee that things happen in the right order.

Tripp answered 20/12, 2011 at 5:0 Comment(4)
So your key point is that the race problem can happen if the .reset is half-baked and paused. In other word, if .reset is NOT an atomic operation, then the problem will happen.Sought
Yes, the interaction of reset and the ! operator are the essential point. If you look at the implementation of these methods, remember also that the compiler may reorder some statements for you (a common cause of double-checked locking woes).Tripp
How is a partially initialized object possible ? new some_resource is atomic since C++ operator new is guaranteed to first allocate and then initialize the object before return it to caller. Are you saying the the .reset implementation may split these 2 steps?Expert
@Expert it is only partially initialized for a short amount of time, and before thread 1's constructor returns. However, thread 1 may still be running the constructor (initializing the object) when thread 2 tries to use the object (as the heap allocation mutex has ended but the object initialization is still in progress). C++ operator new is not atomic -- that's what this whole question is about.Tripp
C
6

The simplest problem scenario is in the case where the intialization of some_resource doesn't depend on resource_ptr. In that case, the compiler is free to assign a value to resource_ptr before it fully constructs some_resource.

For example, if you think of the operation of new some_resource as consisting of two steps:

  • allocate the memory for some_resource
  • initialize some_resource (for this discussion, I'm going to make the simplifying assumption that this initialization can't throw an exception)

Then you can see that the compiler could implement the mutex-protected section of code as:

1. allocate memory for `some_resource`
2. store the pointer to the allocated memory in `resource_ptr`
3. initialize `some_resource`

Now it becomes clear that if another thread executes the function between steps 2 and 3, then resource_ptr->do_something() could be called while some_resource has not been initialized.

Note that it's also possible on some processor architectures for this kind of reordering to occur in hardware unless the proper memory barriers are in place (and such barriers would be implemented by the mutex).

Compellation answered 20/12, 2011 at 10:45 Comment(1)
For "new someresource; ", this is guaranteed by C++ operator new that the resource is initialized before return. This is how operator new always work in C++, right ? How come the step 1/3 separated? Am I missing something?Expert

© 2022 - 2024 — McMap. All rights reserved.