Here is a RateLimiter
class that you could use in order to limit the frequency of the asynchronous operations. It is a simpler implementation of the RateLimiter
class that is found in this answer.
/// <summary>
/// Limits the number of workflows that can access a resource during the
/// specified time span.
/// </summary>
public class RateLimiter : IDisposable
{
private readonly SemaphoreSlim _semaphore;
private readonly TimeSpan _timeUnit;
private readonly CancellationTokenSource _disposeCts;
private readonly CancellationToken _disposeToken;
private bool _disposed;
public RateLimiter(int maxActionsPerTimeUnit, TimeSpan timeUnit)
{
if (maxActionsPerTimeUnit < 1)
throw new ArgumentOutOfRangeException(nameof(maxActionsPerTimeUnit));
if (timeUnit < TimeSpan.Zero || timeUnit.TotalMilliseconds > Int32.MaxValue)
throw new ArgumentOutOfRangeException(nameof(timeUnit));
_semaphore = new SemaphoreSlim(maxActionsPerTimeUnit, maxActionsPerTimeUnit);
_timeUnit = timeUnit;
_disposeCts = new CancellationTokenSource();
_disposeToken = _disposeCts.Token;
}
public async Task WaitAsync(CancellationToken cancellationToken = default)
{
await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
ScheduleSemaphoreRelease();
}
private async void ScheduleSemaphoreRelease()
{
try { await Task.Delay(_timeUnit, _disposeToken).ConfigureAwait(false); }
catch (OperationCanceledException) { } // Ignore
lock (_semaphore) { if (!_disposed) _semaphore.Release(); }
}
/// <summary>Call Dispose when you are finished using the RateLimiter.</summary>
public void Dispose()
{
lock (_semaphore)
{
if (_disposed) return;
_semaphore.Dispose();
_disposed = true;
_disposeCts.Cancel();
_disposeCts.Dispose();
}
}
}
Usage example:
List<string> urls = GetUrls();
using var rateLimiter = new RateLimiter(20, TimeSpan.FromMinutes(1.0));
string[] documents = await Task.WhenAll(urls.Select(async url =>
{
await rateLimiter.WaitAsync();
return await _httpClient.GetStringAsync(url);
}));
Note: I added a Dispose
method so that the asynchronous operations initiated internally by the RateLimiter
class can be canceled. This
method should be called when you are finished using the RateLimiter
, otherwise the pending asynchronous operations will prevent
the RateLimiter
from being garbage collected in a timely manner, on top of consuming resources associated with active Task.Delay
tasks.
The original very simple but leaky implementation can be found in the 2nd revision of this answer.
I am adding an alternative implementation of the RateLimiter
class, more complex, which is based on a Stopwatch
instead of a SemaphoreSlim
. It has the advantage that it doesn't need to be disposable, since it's not launching hidden asynchronous operations in the background. The disadvantages are that the WaitAsync
method does not support a CancellationToken
argument, and that the probability of bugs is higher because of the complexity.
public class RateLimiter
{
private readonly Stopwatch _stopwatch;
private readonly Queue<TimeSpan> _queue;
private readonly int _maxActionsPerTimeUnit;
private readonly TimeSpan _timeUnit;
public RateLimiter(int maxActionsPerTimeUnit, TimeSpan timeUnit)
{
// Arguments validation omitted
_stopwatch = Stopwatch.StartNew();
_queue = new Queue<TimeSpan>();
_maxActionsPerTimeUnit = maxActionsPerTimeUnit;
_timeUnit = timeUnit;
}
public Task WaitAsync()
{
var delay = TimeSpan.Zero;
lock (_stopwatch)
{
var currentTimestamp = _stopwatch.Elapsed;
while (_queue.Count > 0 && _queue.Peek() < currentTimestamp)
{
_queue.Dequeue();
}
if (_queue.Count >= _maxActionsPerTimeUnit)
{
var refTimestamp = _queue
.Skip(_queue.Count - _maxActionsPerTimeUnit).First();
delay = refTimestamp - currentTimestamp;
Debug.Assert(delay >= TimeSpan.Zero);
if (delay < TimeSpan.Zero) delay = TimeSpan.Zero; // Just in case
}
_queue.Enqueue(currentTimestamp + delay + _timeUnit);
}
if (delay == TimeSpan.Zero) return Task.CompletedTask;
return Task.Delay(delay);
}
}
与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…