Be Careful When Defining Singleton Using Double-Checked Locking

Initializing instance must be an atomic method. Otherwise, there should be some unexpected behaviors.

For example, let’s say that we are going to define a singleton which contains a property in int type with 1 as value. If the singleton is initialized, reuse the instance of the singleton.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

public sealed class Singleton
{
private static Singleton _instance;
private static readonly object LockObject = new object();
private int _number;

private Singleton()
{
}

public static Singleton Instance
{
get
{
if (_instance == null)
{
lock (LockObject)
{
if (_instance == null)
{
_instance = new Singleton();
Task.Delay(100).Wait(); // Sleep for 10 ms
_instance._number= 1; // Set number as 1
}
}
}
return _instance;
}
}

public int Number => _number;
}

class Program
{
static void Main()
{
// Create multiple tasks to simulate concurrent access
var tasks = new List<Task>();
for (int i = 0; i < 10; i++)
{
tasks.Add(Task.Run(() =>
{
var singleton = Singleton.Instance;
Console.WriteLine($"Thread {Task.CurrentId}: Number count = {singleton.Number}");
}));
}

Task.WaitAll(tasks.ToArray());

var singleton = Singleton.Instance;
Console.WriteLine($"Finally: Number count = {singleton.Number}");
}
}

If we define the Singleton class like above, what will be shown in the console?

A:

1
2
3
4
5
6
7
8
9
10
11
Thread 1: Number count = 0
Thread 2: Number count = 0
Thread 3: Number count = 0
Thread 4: Number count = 0
Thread 5: Number count = 0
Thread 6: Number count = 0
Thread 9: Number count = 1
Thread 10: Number count = 1
Thread 7: Number count = 1
Thread 8: Number count = 1
Finally: Number count = 1

B:

1
2
3
4
5
6
7
8
9
10
11
Thread 1: Number count = 1
Thread 2: Number count = 1
Thread 3: Number count = 1
Thread 4: Number count = 1
Thread 5: Number count = 1
Thread 6: Number count = 1
Thread 9: Number count = 1
Thread 10: Number count = 1
Thread 7: Number count = 1
Thread 8: Number count = 1
Finally: Number count = 1

Unfortunately, the answer is A. As an explanation, when one thread initialize the instance by _instance = new Singleton();, one another code find the instance is not null and expect the Number has been set to 1. But actually, the value of Number property is not set yet. Then, the DCL (double-checked locking) doesn’t achieve the target it is used in this case.


Update on April 3th, 2024

The article Double-checked lock is not thread-safe says the insecurity of using double-checked lock in C#. It recommends several ways to make the code thread-safe:

  • Avoid double-checked locking, and simply perform everything within the lock statement.
  • Make the field volatile using the volatile keyword.
  • Use the System.Lazy class, which is guaranteed to be thread-safe. This can often lead to more elegant code.
  • Use System.Threading.LazyInitializer.

Examples can be found in the original article.