The use case is this:
With these rules in mind, is this thread safe?
public struct ThreadSafeData<T>
{
private T[] dataArray;
private int setterIndex;
private int lastSetIndex;
public void Init()
{
dataArray = new T[2];
}
public void SetData(T data)
{
dataArray[setterIndex] = data;
lastSetIndex = setterIndex;
// Convert 0 to 1, and 1 to 0
setterIndex = lastSetIndex * - 1 + 1;
}
public T GetData()
{
return dataArray[lastSetIndex];
}
}
UPDATE (requested in comments)
What I would like to achieve is the following; I want to avoid tearing and want the reader to always read the last value written by the writer. I tried doing that with a single T field, but then I encountered (what I think is) tearing. For example, in the tests (see below) I always write a Vector2Int with 0,0 or 1,1. But the reader would sometimes read 1,0 when using a single T field. This is why I added the Array (and added the "data integrity" check to my tests).
I am using X64 architecture. And this is the Vector2Int I use in my tests: https://docs.unity3d.com/ScriptReference/Vector2Int.html
Questions
How do I know if this is thread safe (if it is)? I have run tests for quite a while. But how do I know for sure?
Do you know a better solution for this use case? Please let me know!
Tests
I am making a game in Unity and have run tests where the "writing thread" runs at 30, 60 or 90fps, and doing up to 300 writes per frame. And a "reading thread" running from 30 to 300fps (doing 1 read per frame).
The test data (T) I used was a struct with a Vector2Int and a bool. To check the data integrity, the "reader" checked if the x and y of the Vector2Int are 1 when the bool is true and when false, x and y have to be 0 (it throws an error when this is wrong).
I ran these test for about an hour and never got any errors. But I am not sure if that means that this always works correct.
(ps. I don't really care if this template is a struct or class; I am not sure yet what will work best for me)
I managed to reproduce a torn value with your ThreadSafeData
implementation, using as T
the type ValueTuple<int, int, int, int, int, int, int, int, int>
, and mutating it using the same Random.Next()
value for all nine fields of the tuple. Demo. Hundreds of torn T
values per second are observed. Like this value:
(373331022, 373331022, 373331022, 373331022, 373331022, 373331022, 373331022, 1480972221, 1480972221)
Here are some alternative implementations of the ThreadSafeData<T>
struct, that are truly safe. Using the lock
statement is definitely thread-safe, and also very simple:
public struct ThreadSafeData<T>
{
private readonly object _locker = new();
private T _data = default;
public ThreadSafeData() { }
public void SetData(T data) { lock (_locker) _data = data; }
public T GetData() { lock (_locker) return _data; }
}
The cost of an uncontended lock
is in the magnitude of ~20 nanoseconds, so it's quite cheap, but if your readers are calling the GetData
very frequently then you might want a faster solution. This solution is not the ReaderWriterLockSlim
though:
public struct ThreadSafeData<T>
{
private readonly ReaderWriterLockSlim _lock = new();
private T _data = default;
public ThreadSafeData() { }
public void SetData(T data)
{
_lock.EnterWriteLock();
try { _data = data; }
finally { _lock.ExitWriteLock(); }
}
public T GetData()
{
_lock.EnterReadLock();
try { return _data; }
finally { _lock.ExitReadLock(); }
}
}
This is actually a little slower than the lock
, because the work that is performed by the writer is too lightweight. The ReaderWriterLockSlim
is advantageous when the writer does chunky work.
A better alternative regarding performance, but worse regarding memory allocations, is to store the T
value in a volatile
object
field:
public struct ThreadSafeData<T>
{
private volatile object _data = default(T);
public ThreadSafeData() { }
public void SetData(T data) => _data = data;
public T GetData() => (T)_data;
}
This will cause boxing in case the T
is a value type, and a new box will be allocated on each SetData
operation. According to my experiments the size of the box is 16 bytes + the size of the T
. The performance boost compared to the lock
(in scenarios with high contention), is around x10.
Seqlock: Peter Cordes mentioned in their answer the interesting idea of the Seqlock. Below is an implementation of this idea. My tests don't reveal any tearing. Most likely the implementation below is safe for a single writer - multiple readers scenario, but I wouldn't bet my life on it. The advantage of this approach over the above volatile object
, is that it doesn't allocate memory.
public struct ThreadSafeData<T> // Safe with a single writer only
{
private volatile int _seq;
private T _data;
public void SetData(T data)
{
_seq++;
Interlocked.MemoryBarrier();
_data = data;
_seq++;
}
public T GetData()
{
SpinWait spinner = default;
while (true)
{
int seq1 = _seq;
if ((seq1 & 1) != 0) goto spin;
T data = _data;
Interlocked.MemoryBarrier();
int seq2 = _seq;
if (seq1 != seq2) goto spin;
return data;
spin:
spinner.SpinOnce();
}
}
}
According to my experiments the performance of the GetData()
is about half of the corresponding volatile object
-based implementation, but still many times faster than the lock
-based, under the condition that the SetData
is called infrequently. Otherwise, if the writer calls the SetData
in a tight loop, the readers will barely be able to read any value at all. Almost always the _seq
will be different before and after reading the _data
, resulting in endless spinning.