3

Here is a simple code snippet for demonstration.

Somebody told me that the double-check lock is incorrect. Since the variable is non-volatile, the compiler is free to reorder the calls or optimize them away(For details, see codereview.stackexchange.com/a/266302/226000).

But I really saw such a code snippet is used in many projects indeed. Could somebody shed some light on this matter? I googled and talked about it with my friends, but I still can't find out the answer.

#include <iostream>
#include <mutex>
#include <fstream>

namespace DemoLogger
{
    void InitFd()
    {
        if (!is_log_file_ready)
        {
            std::lock_guard<std::mutex> guard(log_mutex);
            if (!is_log_file_ready)
            {
                log_stream.open("sdk.log", std::ofstream::out | std::ofstream::trunc);
                is_log_file_ready = true;
            }
        }
    }


    extern static bool is_log_file_ready;
    extern static std::mutex log_mutex;
    extern static std::ofstream log_stream;
}

//cpp
namespace DemoLogger
{
    bool is_log_file_ready{false};
    std::mutex log_mutex;
    std::ofstream log_stream;
}

UPDATE: Thanks to all of you. There is better implementation for InitFd() indeed, but it's only a simple demo indeed, what I really want to know is that whether there is any potential problem with double-check lock or not.

For the complete code snippet, see https://codereview.stackexchange.com/questions/266282/c-logger-by-template.

LWimsey
  • 6,189
  • 2
  • 25
  • 53
John
  • 2,963
  • 11
  • 33
  • 1
    As for you global variables, since C++ you can use `inline` for the variables, so that don't need to declare them as `extern` and initialize them in a source file. – Arthur P. Golubev Aug 29 '21 at 14:02
  • 1
    "Somebody" doesn't know what they're talking about (or you are not faithfully representing what they said). – EOF Aug 29 '21 at 14:11
  • 1
    I dont' see any reason to have a double lock. You could make the bool a std::atomic and check if its true at the start of InitFd() and return immediately if you want to avoid the short lock on the mutex if that's not needed. I also would use std::scoped_lock (if you're on C++17). https://stackoverflow.com/questions/43019598/stdlock-guard-or-stdscoped-lock And I would put the boolean and lock inside a logger class itself as static members. – Pepijn Kramer Aug 29 '21 at 14:32
  • About the atomic bool : https://stackoverflow.com/questions/16320838/when-do-i-really-need-to-use-atomicbool-instead-of-bool/16321311 – Pepijn Kramer Aug 29 '21 at 14:32
  • @EOF For details, see https://codereview.stackexchange.com/a/266302/226000 – John Aug 29 '21 at 14:35
  • @PepijnKramer For the complete code snippet which is really used, see https://codereview.stackexchange.com/questions/266282/c-logger-by-template. – John Aug 29 '21 at 14:45
  • Search the internet for "c++ herb sutter double check lock" and "c++ scott meyers double check lock". There may be a discussion between these two experts about using a double check lock; I could be wrong. However, each one presents a discussion about the double check lock concept. – Thomas Matthews Aug 29 '21 at 18:36
  • @John, Answering the additional question from the update, there are no problems with properly implemented double-check lock, and the proper implementation is possible in C++. Details of such implementations are present in my answer post. Also, now there is a post in regards to compiling C++ code with data races, which is your case: https://stackoverflow.com/questions/69062079/does-c-compiler-have-to-compile-multi-threaded-code-with-data-race-as-coded – Arthur P. Golubev Sep 05 '21 at 09:29

3 Answers3

5

The double-checked lock is incorrect because is_log_file_ready is a plain bool, and this flag can be accessed by multiple threads one of which is a writer - that is a race

The simple fix is to change the declaration:

std::atomic<bool> is_log_file_ready{false};

You can then further relax operations on is_log_file_ready:

void InitFd()
{
    if (!is_log_file_ready.load(std::memory_order_acquire))
    {
        std::lock_guard<std::mutex> guard(log_mutex);

        if (!is_log_file_ready.load(std::memory_order_relaxed))
        {
            log_stream.open("sdk.log", std::ofstream::out | std::ofstream::trunc);
            is_log_file_ready.store(true, std::memory_order_release);
        }
    }
}

But in general, double-checked locking should be avoided except in low-level implementations.

As suggested by Arthur P. Golubev, C++ offers primitives to do this, such as std::call_once

Update:

Here's an example that shows one of the problems a race can cause.

#include <thread>
#include <atomic>

using namespace std::literals::chrono_literals;

int main()
{
    int flag {0}; // wrong !

    std::thread t{[&] { while (!flag); }};

    std::this_thread::sleep_for(20ms);

    flag = 1;
    t.join();
}

The sleep is there to give the thread some time to initialize.
This program should return immediately, but compiled with full optimization -O3, it probably doesn't. This is caused by a valid compiler transformation, that changes the while-loop into something like this:

if (flag) return; while(1);

And if flag is (still) zero, this will run forever (changing the flag type to std::atomic<int> will solve this).

This is only one of the effects of undefined behavior, the compiler does not even have to commit the change to flag to memory.

With a race, or incorrectly set (or missing) barriers, operations can also be re-ordered causing unwanted effects, but these are less likely to occur on X86 since it is a generally more forgiving platform than weaker architectures (although re-ordering effects do exist on X86)

LWimsey
  • 6,189
  • 2
  • 25
  • 53
  • Comments are not for extended discussion; this conversation has been [moved to chat](https://chat.stackoverflow.com/rooms/236631/discussion-on-answer-by-lwimsey-is-there-any-potential-problem-with-double-check). – Machavity Aug 31 '21 at 23:24
1

There is nothing to optimize away here (no commands to be excluded, see the details below), but there are the following:

  1. It is possible that is_log_file is set to true before log_stream opens the file; and then another thread is possible to bypass the outer if block code and start using the stream before the std::ofstream::open has completed.

It could be solved by using std::atomic_thread_fence(std::memory_order_release); memory barrier before setting the flag to true.

Also, a compiler is forbidden to reorder accesses to volatile objects on the same thread (https://en.cppreference.com/w/cpp/language/as_if), but, as for the code specifically, the available set of operator << functions and write function of std::ofstream just is not for volatile objects - it would not be possible to write in the stream if make it volatile (and making volatile only the flag would not permit the reordering).

Note, a protection for is_log_file flag from a data race with C++ standard library means releasing std::memory_order_release or stronger memory order - the most reasonable would be std::atomic/std::atomic_bool (see LWimsey's answer for the sample of the code) - would make reordering impossible because the memory order

  1. Formally, an execution with a data race is considered to be causing undefined behaviour - which in the double-checked lock is actual for is_log_file flag. In a conforming to the standard of the language code, the flag must be protected from a data race (the most reasonable way to do it would be using std::atomic/std::atomic_bool).

Though, in practice, if the compiler is not insane so that intentionally spoils your code (some people wrongly consider undefined behaviour to be what occurs in run-time and does not relate to compilation, but standard operates undefined behaviour to regulate compilation) under the pretext it is allowed everything if undefined behavior is caused (by the way, must be documented; see details of compiling C++ code with a data race in: https://stackoverflow.com/a/69062080/1790694 ) and at the same time if it implements bool reasonably, so that consider any non-zero physical value as true (it would be reasonable since it must convert arithmetics, pointers and some others to bool so), there will never be a problem with partial setting the flag to true (it would not cause a problem when reading); so the only memory barrier std::atomic_thread_fence(std::memory_order_release); before setting the flag to true, so that reordering is prevented, would make your code work without problems.

At https://en.cppreference.com/w/cpp/language/storage_duration#Static_local_variables you can read that implementations of initialization of static local variables since C++11 (which you also should consider to use for one-time actions in general, see the note about what to consider for one-time actions in general below) usually use variants of the double-checked locking pattern, which reduces runtime overhead for already-initialized local statics to a single non-atomic boolean comparison.

This is an examples of exactly that environment-dependent safety of a non-atomic flag which I stated above. But it should be understood that these solutions are environment-dependent, and, since they are parts of implementations of the compilers themselves, but not a program using the compilers, there is no concern of conforming to the standard there.

To make your program corresponding to the standard of the language and be protected (as far as the standard is implemented) against a compiler implementation details liberty, you must protect the flag from data races, and the most reasonable then, would be using std::atomic or std::atomic_bool.


Note, even without protection of the flag from data races:

  • because of the mutex, it is not possible that any thread would not get updates after changing values (both the bool flag and the std::ofstream object) by some thread. The mutex implements the memory barrier, and if we don’t have the update when checking the flag in the first condition clause, we then get it then come to the mutex, and so guaranteedly have the updated value when checking the flag in the second condition clause.

  • because the flag can unobservably be potentially accessed in unpredictable ways from other translation units, the compiler would not be able to avoid writes and reads to the flag under the as-if rule even if the other code of translation unit would be so senseless (such as setting the flag to true and then starting the threads so that no resets to false accessible) that it would be permitted in case the flag is not accessible from other translation units.



For one-time actions in general besides raw protection with flags and mutexes consider using:


All the mentioned multi-threading functionality is available since C++11 (but, since you are already using std::mutex which is available starting since it too, this is the case).


Also, you should correctly handle the cases of opening the file failure.

Also, everyone must protect your std::ofstream object from concurrent operations of writing to the stream.


Answering the additional question from the update of the question, there are no problems with properly implemented double-check lock, and the proper implementation is possible in C++.

  • >It is possible that `is_log_file_ready` is set to true before log_stream opens the file; *Reply*: I really can't understand this.As far as I can see, the `is_log_file_ready` is initialised to `false`, it only will be set to `true` after `std::ofstream::open()` has been invoked. Could you please explain that in more detail for me? – John Aug 31 '21 at 11:52
  • 1
    The definition of "before" in a concurrent program, especially one with data races, is not always what you might expect it to be. See, for example, [here](https://en.cppreference.com/w/cpp/language/memory_model#Threads_and_data_races) – Useless Aug 31 '21 at 17:06
  • @John, Both compiler when compiling and CPU when running can perform different kinds of optimizations including reordering commands. It generally requires non-trivial analysis that the operations depend on each other because of access from different threads, and it is often that hardware only analyzes the single flow, relying on protection against multi-threading issues by a programmer. [Look at the following comment for the continuation…] – Arthur P. Golubev Aug 31 '21 at 17:45
  • [...continuation] As for the particular code, opening file includes communicating with hardware which comparatively is very slow, generally it is a good idea to perform other operations meanwhile waiting for an answer from hardware. And in regards to any other commands, it just may be easy for a running hardware (CPU and other) to perform following operations while previous ones are in progress when does not violate checked by the hardware dependencies. We just must protect against all the possibilities even from weird and illogical but allowed. – Arthur P. Golubev Aug 31 '21 at 17:45
  • @John, I addition. A compiler is forbidden to reorder accesses to `volatile` objects on the same thread (https://en.cppreference.com/w/cpp/language/as_if). But, as for the code specifically, the available set of `operator <<` functions and `write` function of `std::ofstream` just is not for `volatile` objects - it would not be possible to write in the stream if make it `volatile (and making `volatile` only the flag would not permit the reordering). – Arthur P. Golubev Sep 02 '21 at 12:17
  • @ArthurP.Golubev " it would not be possible to write in the stream if make it `volatile`"? I can't understand it. Why it would not be possible to write in the stream if make it `volatile`? – John Sep 05 '21 at 14:13
  • @John, The `volatile` functions(methods) overloads for the class just do not exist. It is not declared why. It may be even that the standardization committee just decided that it would not be worth it, the many additional `volatile` functions(methods) for many classes in the standard library, since they would not be useful enough. – Arthur P. Golubev Sep 05 '21 at 15:34
1

Somebody told me that the double-check lock is incorrect

It usually is.

IIRC double-checked locking originated in Java (whose more strongly-specified memory model made it viable).

From there it spread a plague of ill-informed and incorrect C++ code, presumably because it looks enough like Java to be vaguely plausible.

Since the variable is non-volatile

Double-checked locking cannot be made correct by using volatile for synchronization, because that's not what volatile is for.

Java is perhaps also the source of this misuse of volatile, since it means something entirely different there.

Thanks for linking to the review that suggested this, I'll go and downvote it.

But I really saw such a code snippet is used in many projects indeed. Could somebody shed some light on this matter?

As I say, it's a plague, or really I suppose a harmful meme in the original sense.

I googled and talked about it with my friends, but I still can't find out the answer.

... Is there any potential problem with double-check lock for C++?

There are nothing but problems with double-checked locking for C++. Almost nobody should ever use it. You should probably never copy code from anyone who does use it.

In preference order:

  1. Just use a static local, which is even less effort and still guaranteed to be correct - in fact:

    If multiple threads attempt to initialize the same static local variable concurrently, the initialization occurs exactly once (similar behavior can be obtained for arbitrary functions with std::call_once).

    Note: usual implementations of this feature use variants of the double-checked locking pattern, which reduces runtime overhead for already-initialized local statics to a single non-atomic boolean comparison.

    so you can get correct double-checked locking for free.

  2. Use std::call_once if you need more elaborate initialization and don't want to package it into a class

  3. Use (if you must) double-checked locking with a std::atomic_flag or std::atomic_bool flag and never volatile.

Useless
  • 64,155
  • 6
  • 88
  • 132
  • 1
    `static` and `call_once` implementations did not become thread-safe by itself. Whether you like it or not, at some level, someone has to implement double-checked locking. – Tootsie Sep 02 '21 at 13:42
  • 1
    I know, that's why I quoted the part of cppreference.com that said exactly that. That's no reason to encourage people to roll their own when it's both unnecessary, and there are relatively few correct examples that aren't Java-flavoured garbage misusing `volatile`. – Useless Sep 02 '21 at 14:33
  • @Useless Thank you for your detailed explaination. For double-checked locking, plain `bool` is wrong, why `std::atomic` works? Could you please explain tht in more detail for me? – John Sep 05 '21 at 10:44
  • It's explained in the section on [data races](https://en.cppreference.com/w/cpp/language/memory_model#Threads_and_data_races). – Useless Sep 06 '21 at 07:33
  • As for “Just use a static local”, one may be interested in different lifetime of the object. And one using it must be careful in regards to the fact that this protection for static local variables against a data race is only since C++11. And, it is possible to return an object initialized by its constructor from `std::call_once` through a smart pointer, reference to which is passed as an argument. – Arthur P. Golubev Sep 09 '21 at 17:24
  • As for “Just use a static local”, one may be interested in different lifetime of the object. And one using it must be careful in regards to the fact that this protection for static local variables against a data race is only since C++11. And, it is possible to return an object initialized by its constructor from `std::call_once` through a smart pointer, reference to which is passed as an argument. – Arthur P. Golubev Sep 09 '21 at 17:38