Який хороший алгоритм обмеження швидкості?


155

Я міг би використовувати якийсь псевдо-код, а ще краще, Python. Я намагаюся реалізувати чергу обмеження швидкості для бота Python IRC, і вона частково працює, але якщо хтось запускає менше повідомлень, ніж ліміт (наприклад, обмеження швидкості становить 5 повідомлень за 8 секунд, а людина спрацьовує лише 4), і наступний тригер закінчується протягом 8 секунд (наприклад, 16 секунд пізніше), бот надсилає повідомлення, але черга стає повною, і бот чекає 8 секунд, навіть якщо це не потрібно з моменту закінчення періоду 8 секунд.

Відповіді:


231

Ось найпростіший алгоритм , якщо ви хочете просто відкинути повідомлення, коли вони надто швидко надходять (замість того, щоб ставити їх у чергу, що має сенс, оскільки чергу може бути довільно великою):

rate = 5.0; // unit: messages
per  = 8.0; // unit: seconds
allowance = rate; // unit: messages
last_check = now(); // floating-point, e.g. usec accuracy. Unit: seconds

when (message_received):
  current = now();
  time_passed = current - last_check;
  last_check = current;
  allowance += time_passed * (rate / per);
  if (allowance > rate):
    allowance = rate; // throttle
  if (allowance < 1.0):
    discard_message();
  else:
    forward_message();
    allowance -= 1.0;

У цьому рішенні немає структур даних, таймерів тощо. Це працює чисто: :) Для цього "припуск" зростає зі швидкістю не більше 5/8 одиниць в секунду, тобто максимум п’ять одиниць за вісім секунд. Кожне пересилається повідомлення віднімає одну одиницю, тому ви не можете надсилати більше п'яти повідомлень кожні вісім секунд.

Зауважте, що rateповинно бути ціле число, тобто без ненульової десяткової частини, інакше алгоритм не буде працювати правильно (фактична швидкість не буде rate/per). Наприклад, rate=0.5; per=1.0;це не працює, тому що allowanceніколи не виросте до 1,0. Але rate=1.0; per=2.0;працює чудово.


4
Варто також зазначити, що розмірність і масштаб 'time_passed' повинні бути такими ж, як 'per', наприклад секунди.
скафман

2
Привіт скаффман, спасибі за компліменти --- я викинув його з рукава, але з 99,9% ймовірністю хтось раніше придумав подібне рішення :)
Antti Huima

52
Це стандартний алгоритм - це токенове відро, без черги. Відро є allowance. Розмір відра становить rate. allowance += …Лінія є оптимізацією додавання маркерів кожної швидкості ÷ за секунди.
дероберт

5
@zwirbeltier Те, що ви писали вище, не відповідає дійсності. "Допомога" завжди обмежена "швидкістю" (дивіться на рядок "// дросельна заслінка"), тому це дозволить лише спливати повідомлення "швидкості" в будь-який конкретний час, тобто 5.
Анті Хуїма

7
Це добре, але може перевищувати норму. Скажімо, за час 0 ви пересилаєте 5 повідомлень, потім за час N * (8/5) за N = 1, 2, ... ви можете надіслати ще одне повідомлення, в результаті чого за 8 секунд
з'явилося

48

Використовуйте цей декоратор @RateLimited (ratepersec) перед вашою функцією, яка завдає функції.

В основному, це перевіряє, чи минули 1 / швидкість секунди з останнього разу, а якщо ні, то чекає решту часу, інакше вона не чекає. Це фактично обмежує швидкість / сек. Декоратор може бути застосований до будь-якої функції, яку ви хочете обмежити.

У вашому випадку, якщо вам потрібно максимум 5 повідомлень за 8 секунд, перед функцією sendToQueue використовуйте @RateLimited (0.625).

import time

def RateLimited(maxPerSecond):
    minInterval = 1.0 / float(maxPerSecond)
    def decorate(func):
        lastTimeCalled = [0.0]
        def rateLimitedFunction(*args,**kargs):
            elapsed = time.clock() - lastTimeCalled[0]
            leftToWait = minInterval - elapsed
            if leftToWait>0:
                time.sleep(leftToWait)
            ret = func(*args,**kargs)
            lastTimeCalled[0] = time.clock()
            return ret
        return rateLimitedFunction
    return decorate

@RateLimited(2)  # 2 per second at most
def PrintNumber(num):
    print num

if __name__ == "__main__":
    print "This should print 1,2,3... at about 2 per second."
    for i in range(1,100):
        PrintNumber(i)

Мені подобається ідея використовувати декоратор для цієї мети. Чому останнійTimeCalled список? Також я сумніваюся, що це буде працювати, коли кілька потоків викликають одну і ту ж функцію RateLimited ...
Stephan202

8
Це список, тому що такі прості типи, як float, є постійними, коли їх захоплює закриття. Складаючи список, список є постійним, але його вміст - ні. Так, це не безпечно для різьби, але це можна легко зафіксувати замками.
Карлос А. Ібарра

time.clock()у моїй системі недостатньо роздільної здатності, тому я адаптував код і змінив його на використанняtime.time()
mtrbean

3
Для обмеження швидкості, ви точно не хочете використовувати time.clock(), що вимірює минулий час процесора. Час процесора може працювати набагато швидше або набагато повільніше, ніж "фактичний" час. Ви хочете використовувати time.time()натомість, який вимірює час стіни ("фактичний" час).
Джон Вісман

1
BTW для реальних виробничих систем: реалізація обмеження швидкості при виклику сну () може бути не дуже хорошою ідеєю, оскільки вона блокує потік і, таким чином, заважає іншому клієнту використовувати його.
Мареш

28

Відро Token - це досить просто у виконанні.

Почніть з відра з 5 жетонами.

Кожні 5/8 секунд: Якщо у відрі менше 5 жетонів, додайте його.

Щоразу, коли ви бажаєте надіслати повідомлення: Якщо у відрі є ≥1 маркер, вийміть один маркер і надішліть повідомлення. В іншому випадку зачекайте / відпустіть повідомлення / що завгодно.

(очевидно, що в фактичному коді ви використовуєте цілий лічильник замість реальних маркерів, і ви можете оптимізувати кожні 5/8-й крок, зберігаючи часові позначки)


Прочитавши ще раз питання, якщо обмеження швидкості повністю скидається кожні 8 секунд, то тут є модифікація:

Почніть з мітки часу, last_sendдавно, колись (наприклад, в епоху). Також почніть з того ж відра з 5 жетонами.

Страйкуйте кожні 5/8 секунд.

Кожен раз, коли ви надсилаєте повідомлення: Спочатку перевірте, чи не було last_send≥ 8 секунд тому. Якщо це так, заповніть відро (встановіть його на 5 жетонів). По-друге, якщо у відрі є лексеми, надішліть повідомлення (інакше, drop / wait / тощо). По-третє, встановлено last_sendзараз.

Це повинно працювати за таким сценарієм.


Я фактично написав бота IRC, використовуючи таку стратегію (перший підхід). Його в Perl, а не в Python, але ось який-небудь код для ілюстрації:

Перша частина тут обробляє додавання жетонів до відра. Ви можете побачити оптимізацію додавання жетонів на основі часу (2-й до останнього рядка), а потім останній рядок максимально затискає вміст відра (MESSAGE_BURST)

    my $start_time = time;
    ...
    # Bucket handling
    my $bucket = $conn->{fujiko_limit_bucket};
    my $lasttx = $conn->{fujiko_limit_lasttx};
    $bucket += ($start_time-$lasttx)/MESSAGE_INTERVAL;
    ($bucket <= MESSAGE_BURST) or $bucket = MESSAGE_BURST;

$ conn - це структура даних, яка передається навколо. Це всередині методу, який працює звичайно (він обчислює, коли наступного разу щось буде робити, і спить або так довго, поки не отримає мережевий трафік). Наступна частина методу обробляє надсилання. Це досить складно, оскільки повідомлення мають пріоритети, пов'язані з ними.

    # Queue handling. Start with the ultimate queue.
    my $queues = $conn->{fujiko_queues};
    foreach my $entry (@{$queues->[PRIORITY_ULTIMATE]}) {
            # Ultimate is special. We run ultimate no matter what. Even if
            # it sends the bucket negative.
            --$bucket;
            $entry->{code}(@{$entry->{args}});
    }
    $queues->[PRIORITY_ULTIMATE] = [];

Це перша черга, яка запускається незалежно від того. Навіть якщо це стане причиною того, що наше з'єднання загине за затоплення. Використовується для надзвичайно важливих речей, таких як відповідь на PING сервера. Далі, решта черг:

    # Continue to the other queues, in order of priority.
    QRUN: for (my $pri = PRIORITY_HIGH; $pri >= PRIORITY_JUNK; --$pri) {
            my $queue = $queues->[$pri];
            while (scalar(@$queue)) {
                    if ($bucket < 1) {
                            # continue later.
                            $need_more_time = 1;
                            last QRUN;
                    } else {
                            --$bucket;
                            my $entry = shift @$queue;
                            $entry->{code}(@{$entry->{args}});
                    }
            }
    }

Нарешті, статус відра зберігається назад у структурі даних $ conn (фактично трохи пізніше методу; він спочатку обчислює, як скоро у нього з’явиться більше роботи)

    # Save status.
    $conn->{fujiko_limit_bucket} = $bucket;
    $conn->{fujiko_limit_lasttx} = $start_time;

Як бачите, фактичний код обробки відра дуже малий - приблизно чотири рядки. Решта коду - це пріоритетна робота з чергою. У бота є черги з пріоритетом, так що, наприклад, хтось із ним в чаті не може перешкодити виконувати свої важливі обов'язки щодо удару / заборони.


Чи щось мені не вистачає ... схоже, це обмежує вас
одним

@ chills42: Так, я неправильно прочитав питання ... див. другу половину відповіді.
дероберт

@chills: Якщо last_send становить <8 секунд, ви не додаєте жодних маркерів у відро. Якщо ваше відро містить жетони, ви можете надіслати повідомлення; в іншому випадку ви не можете (ви вже надіслали 5 повідомлень за останні 8 секунд)
derobert

3
Буду вдячний, якщо люди, котрі не хочуть це сказати, пояснюють, чому ... Я б хотів виправити будь-які проблеми, які ви бачите, але без зворотного зв'язку це важко зробити!
дероберт

10

щоб заблокувати обробку, поки повідомлення не може бути надіслане, таким чином, в черзі подальших повідомлень, прекрасне рішення antti також може бути змінено таким чином:

rate = 5.0; // unit: messages
per  = 8.0; // unit: seconds
allowance = rate; // unit: messages
last_check = now(); // floating-point, e.g. usec accuracy. Unit: seconds

when (message_received):
  current = now();
  time_passed = current - last_check;
  last_check = current;
  allowance += time_passed * (rate / per);
  if (allowance > rate):
    allowance = rate; // throttle
  if (allowance < 1.0):
    time.sleep( (1-allowance) * (per/rate))
    forward_message();
    allowance = 0.0;
  else:
    forward_message();
    allowance -= 1.0;

він просто чекає, поки буде достатньо надбавки, щоб надіслати повідомлення. не починати з двократної ставки, надбавка може також ініціалізуватися з 0.


5
Коли ви спите (1-allowance) * (per/rate), вам потрібно додати ту саму суму last_check.
Альп

2

Зберігайте час, коли були надіслані останні п'ять рядків. Тримайте повідомлення в черзі до тих пір, поки повідомлення, яке належить до п'ятого за останнім часом (якщо воно існує), пройшло щонайменше 8 секунд (з last_five як масив разів):

now = time.time()
if len(last_five) == 0 or (now - last_five[-1]) >= 8.0:
    last_five.insert(0, now)
    send_message(msg)
if len(last_five) > 5:
    last_five.pop()

Не з тих пір, як ви це переглянули, я не.
Песто

Ви зберігаєте п'ять часових марок і багаторазово переміщуєте їх через пам'ять (або робите операції з пов’язаним списком). Я зберігаю один цілий лічильник і одну часову позначку. І тільки займаюся арифметикою та присвоюванням.
дероберт

2
За винятком того, що міна буде функціонувати краще, якщо намагаються надіслати 5 рядків, але в період часу дозволено лише 3. Ваші дозволить надіслати перші три, і змусити 8 секунд зачекати, перш ніж надсилати 4 і 5. Шахта дозволить відправити 4 і 5 через 8 секунд після четвертої та п'ятої останніх рядків.
Песто

1
Але з цього приводу продуктивність може бути покращена за допомогою кругового пов'язаного списку довжиною 5, який вказує на п’яте за останнім пересиланням, перезапис його при новому відправці та переміщення вказівника вперед.
Песто

для бота irc з обмежувачем швидкості швидкості - це не проблема. Я вважаю за краще рішення списку, оскільки воно є більш читабельним. відповідь відра, що дана, є заплутаною через перегляд, але в цьому немає нічого поганого.
jheriko

2

Одне рішення - приєднати часову позначку до кожного елемента черги та відмовитись від об'єкта після проходження 8 секунд. Ви можете виконувати цю перевірку кожного разу, коли черга додається.

Це працює лише в тому випадку, якщо ви обмежите розмір черги на 5 і відкинете будь-які доповнення, поки черга заповнена.


1

Якщо когось все ще цікавить, я використовую цей простий клас дзвінка в поєднанні з тимчасовим сховищем ключа LRU для обмеження швидкості запиту на IP. Використовує deque, але може бути переписаний для використання зі списком.

from collections import deque
import time


class RateLimiter:
    def __init__(self, maxRate=5, timeUnit=1):
        self.timeUnit = timeUnit
        self.deque = deque(maxlen=maxRate)

    def __call__(self):
        if self.deque.maxlen == len(self.deque):
            cTime = time.time()
            if cTime - self.deque[0] > self.timeUnit:
                self.deque.append(cTime)
                return False
            else:
                return True
        self.deque.append(time.time())
        return False

r = RateLimiter()
for i in range(0,100):
    time.sleep(0.1)
    print(i, "block" if r() else "pass")

1

Просто реалізація пітону коду з прийнятої відповіді.

import time

class Object(object):
    pass

def get_throttler(rate, per):
    scope = Object()
    scope.allowance = rate
    scope.last_check = time.time()
    def throttler(fn):
        current = time.time()
        time_passed = current - scope.last_check;
        scope.last_check = current;
        scope.allowance = scope.allowance + time_passed * (rate / per)
        if (scope.allowance > rate):
          scope.allowance = rate
        if (scope.allowance < 1):
          pass
        else:
          fn()
          scope.allowance = scope.allowance - 1
    return throttler


0

Як щодо цього:

long check_time = System.currentTimeMillis();
int msgs_sent_count = 0;

private boolean isRateLimited(int msgs_per_sec) {
    if (System.currentTimeMillis() - check_time > 1000) {
        check_time = System.currentTimeMillis();
        msgs_sent_count = 0;
    }

    if (msgs_sent_count > (msgs_per_sec - 1)) {
        return true;
    } else {
        msgs_sent_count++;
    }

    return false;
}

0

Мені потрібна була варіація Scala. Ось:

case class Limiter[-A, +B](callsPerSecond: (Double, Double), f: A  B) extends (A  B) {

  import Thread.sleep
  private def now = System.currentTimeMillis / 1000.0
  private val (calls, sec) = callsPerSecond
  private var allowance  = 1.0
  private var last = now

  def apply(a: A): B = {
    synchronized {
      val t = now
      val delta_t = t - last
      last = t
      allowance += delta_t * (calls / sec)
      if (allowance > calls)
        allowance = calls
      if (allowance < 1d) {
        sleep(((1 - allowance) * (sec / calls) * 1000d).toLong)
      }
      allowance -= 1
    }
    f(a)
  }

}

Ось як це можна використовувати:

val f = Limiter((5d, 8d), { 
  _: Unit  
    println(System.currentTimeMillis) 
})
while(true){f(())}
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.