c# – Simple Leaky Bucket Async and Low Footprint

I’m working on a simple Leaky Bucket algorithm.
I have found a lot of samples on the internet, but something always bothers me.
Most of them use Collections and DateTime to track current actions and calculate the time of a Delay.

So, I try a controversial (according to my co-workers) implementation, using 2 semaphores and a separate thread to do the leaky job.

That’s in production now, handling thousands of requests per second without a hitch.

I have 3 questions:

  1. Is it a problem to use a semaphore inside another?
  2. Do you guys see any problem with this code or this approach?
  3. Can we optimize something to gain better performance?

SimpleLeakyBucket.cs

namespace Limiter
{
    using System;
    using System.Threading;
    using System.Threading.Tasks;

    public class SimpleLeakyBucket : IDisposable
    {
        readonly SemaphoreSlim semaphore = new SemaphoreSlim(1, 1);
        readonly SemaphoreSlim semaphoreMaxFill = new SemaphoreSlim(1, 1);
        readonly CancellationTokenSource leakToken = new CancellationTokenSource();

        readonly Config configuration;
        readonly Task leakTask;
        int currentItems = 0;

        public SimpleLeakyBucket(Config configuration)
        {
            this.configuration = configuration;
            leakTask = Task.Run(Leak, leakToken.Token); //start leak task here
        }

        public async Task Wait(CancellationToken cancellationToken)
        {
            await semaphore.WaitAsync(cancellationToken);

            try
            {
                if (currentItems >= configuration.MaxFill)
                {
                    await semaphoreMaxFill.WaitAsync(cancellationToken);
                }

                Interlocked.Increment(ref currentItems);

                return;
            }
            finally
            {
                semaphore.Release();
            }
        }


        void Leak()
        {
            //Wait for our first queue item. 
            while (currentItems == 0 && !leakToken.IsCancellationRequested)
            {
                Thread.Sleep(100);
            }

            while (!leakToken.IsCancellationRequested)
            {
                Thread.Sleep(configuration.LeakRateTimeSpan);

                if (currentItems > 0)
                {
                    var leak = Math.Min(currentItems, configuration.LeakRate);
                    Interlocked.Add(ref currentItems, -leak);

                    if (semaphoreMaxFill.CurrentCount == 0)
                    {
                        semaphoreMaxFill.Release();
                    }
                }
            }
        }


        public void Dispose()
        {
            if (!leakToken.IsCancellationRequested)
            {
                leakToken.Cancel();
                leakTask.Wait();
            }

            GC.SuppressFinalize(this);
        }


        public class Config
        {
            public int MaxFill { get; set; }
            public TimeSpan LeakRateTimeSpan { get; set; }
            public byte LeakRate { get; set; }
        }
    }
}    

Usage sample

namespace LimiterPlayground
{
    class Program
    {
        static void Main(string() args)
        {
            //will limite the execution by 100 per 10 second
            using var leaky = new SimpleLeakyBucket(new SimpleLeakyBucket.Config
            {
                LeakRate = 100,
                LeakRateTimeSpan = TimeSpan.FromSeconds(10),
                MaxFill = 100
            });

            Task.WaitAll(Enumerable.Range(1, 100000).Select(async idx =>
            {
                await leaky.Wait(CancellationToken.None);
                Console.WriteLine($"({DateTime.Now:HH:mm:ss.fff}) - {idx.ToString().PadLeft(5, '0')}");
            }).ToArray());

        }
    }
}