diff --git a/src/Tgstation.Server.Host/Utils/FifoSemaphore.cs b/src/Tgstation.Server.Host/Utils/FifoSemaphore.cs
new file mode 100644
index 0000000000..7a4e200c9c
--- /dev/null
+++ b/src/Tgstation.Server.Host/Utils/FifoSemaphore.cs
@@ -0,0 +1,111 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Tgstation.Server.Host.Utils
+{
+ ///
+ /// A first-in first-out async semaphore.
+ ///
+ /// This is contentious and could be re-written using s to make it promise based. However, it has a lower memory footprint without them and is fine for our uses.
+ sealed class FifoSemaphore : IDisposable
+ {
+ ///
+ /// to represent a ticket in the and whether or not it is .
+ ///
+ class FifoSemaphoreTicket
+ {
+ ///
+ /// Set if the wait operation on a was cancelled to avoid clogging the queue.
+ ///
+ public bool Abandoned { get; set; }
+ }
+
+ ///
+ /// The backing .
+ ///
+ readonly SemaphoreSlim semaphore;
+
+ ///
+ /// The of ticket s.
+ ///
+ readonly Queue ticketQueue;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public FifoSemaphore()
+ {
+ ticketQueue = new Queue();
+ semaphore = new SemaphoreSlim(1);
+ }
+
+ ///
+ public void Dispose() => semaphore.Dispose();
+
+ ///
+ /// Locks the .
+ ///
+ /// The for the operation.
+ /// A resulting in the locked .
+ public async ValueTask Lock(CancellationToken cancellationToken)
+ {
+ FifoSemaphoreTicket ticket = null;
+ using (cancellationToken.Register(
+ () =>
+ {
+ if (ticket != null)
+ ticket.Abandoned = true;
+ }))
+ while (true)
+ {
+ var context = await SemaphoreSlimContext.Lock(semaphore, cancellationToken);
+ try
+ {
+ FifoSemaphoreTicket peekedTicket = null;
+ while (ticketQueue.Count > 0)
+ {
+ peekedTicket = ticketQueue.Peek();
+ if (peekedTicket.Abandoned)
+ ticketQueue.Dequeue();
+ else
+ break;
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ bool goTime;
+ if (ticketQueue.Count == 0)
+ goTime = true;
+ else if (ticket == null)
+ {
+ ticket = new FifoSemaphoreTicket();
+ cancellationToken.ThrowIfCancellationRequested();
+ ticketQueue.Enqueue(ticket);
+ goTime = false;
+ }
+ else
+ {
+ goTime = peekedTicket == ticket;
+ if (goTime)
+ ticketQueue.Dequeue();
+ }
+
+ if (goTime)
+ {
+ var localContext = context;
+ context = null;
+ return localContext;
+ }
+ }
+ finally
+ {
+ context?.Dispose();
+ }
+
+ await Task.Yield();
+ }
+ }
+ }
+}
diff --git a/tests/Tgstation.Server.Host.Tests/Utils/TestFifoSemaphore.cs b/tests/Tgstation.Server.Host.Tests/Utils/TestFifoSemaphore.cs
new file mode 100644
index 0000000000..d09716610c
--- /dev/null
+++ b/tests/Tgstation.Server.Host.Tests/Utils/TestFifoSemaphore.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Tgstation.Server.Host.Utils.Tests
+{
+ [TestClass]
+ public sealed class TestFifoSemaphore
+ {
+ [TestMethod]
+ public async Task TestContention()
+ {
+ const int Count = 1000000;
+
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ using var semaphore = new FifoSemaphore();
+ var tcs = new TaskCompletionSource();
+
+ var orderTotal = 0;
+ var orderActual = 0;
+
+ async Task LockAndUnlock(int? expectedOrder)
+ {
+ try
+ {
+ using (await semaphore.Lock(expectedOrder.HasValue ? CancellationToken.None : cts.Token))
+ {
+ await tcs.Task;
+ Assert.AreEqual(expectedOrder.Value, ++orderActual);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ Assert.IsFalse(expectedOrder.HasValue);
+ }
+ }
+
+ var tasks = new List(Count);
+ for (var i = 0; i < Count; ++i)
+ tasks.Add(
+ LockAndUnlock(
+ (i % 3 == 0)
+ ? null
+ : ++orderTotal));
+
+ var totalCancelled = tasks.Count(x => x.IsCompleted);
+ Assert.AreEqual((Count / 3) + 1, totalCancelled);
+
+ tcs.SetResult();
+
+ await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromMinutes(2));
+ }
+ }
+}