Che cos'è un buon algoritmo di limitazione della velocità?


155

Potrei usare qualche pseudo-codice, o meglio, Python. Sto cercando di implementare una coda di limitazione della velocità per un bot IRC Python e funziona parzialmente, ma se qualcuno attiva meno messaggi rispetto al limite (ad esempio, il limite di velocità è di 5 messaggi ogni 8 secondi e la persona attiva solo 4), e il successivo trigger ha una durata superiore agli 8 secondi (ad es. 16 secondi dopo), il bot invia il messaggio, ma la coda si riempie e il bot attende 8 secondi, anche se non è necessario poiché il periodo di 8 secondi è scaduto.

Risposte:


231

Ecco l' algoritmo più semplice , se vuoi semplicemente eliminare i messaggi quando arrivano troppo rapidamente (invece di metterli in coda, il che ha senso perché la coda potrebbe diventare arbitrariamente grande):

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;

Non ci sono strutture di dati, timer, ecc. In questa soluzione e funziona in modo pulito :) Per vedere questo, la "tolleranza" aumenta alla velocità di 5/8 unità al secondo al massimo, cioè al massimo cinque unità per otto secondi. Ogni messaggio che viene inoltrato deduce un'unità, quindi non è possibile inviare più di cinque messaggi ogni otto secondi.

Si noti che ratedovrebbe essere un numero intero, ovvero senza una parte decimale diversa da zero, oppure l'algoritmo non funzionerà correttamente (il tasso effettivo non sarà rate/per). Ad esempio rate=0.5; per=1.0;, non funziona perché allowancenon crescerà mai fino a 1.0. Ma rate=1.0; per=2.0;funziona benissimo.


4
Vale anche la pena sottolineare che la dimensione e la scala di "time_passed" devono essere uguali a "per", ad es. Secondi.
Skaffman,

2
Ciao skaffman, grazie per i complimenti --- me l'ho buttato fuori dalla manica ma con una probabilità del 99,9% qualcuno ha in precedenza trovato una soluzione simile :)
Antti Huima,

52
Questo è un algoritmo standard: è un token bucket, senza coda. Il secchio è allowance. La dimensione del secchio è rate. La allowance += …linea è un'ottimizzazione dell'aggiunta di un token ogni rate ÷ al secondo.
derobert,

5
@zwirbeltier Quello che scrivi sopra non è vero. 'Allowance' è sempre limitato da 'rate' (guarda la riga "// throttle"), quindi consentirà solo una raffica di messaggi 'rate' esattamente in qualsiasi momento, ad esempio 5.
Antti Huima

8
Questo è buono, ma può superare il tasso. Diciamo che al momento 0 inoltri 5 messaggi, quindi al momento N * (8/5) per N = 1, 2, ... puoi inviare un altro messaggio, risultando in più di 5 messaggi in un periodo di 8 secondi
mindvirus

48

Usa questo decoratore @RateLimited (ratepersec) prima della tua funzione che si accoda.

Fondamentalmente, questo controlla se sono passati 1 / rate secondi dall'ultima volta e, in caso contrario, attende il resto del tempo, altrimenti non aspetta. Questo ti limita effettivamente a votare / sec. Il decoratore può essere applicato a qualsiasi funzione desideri limitata.

Nel tuo caso, se vuoi un massimo di 5 messaggi per 8 secondi, usa @RateLimited (0.625) prima della tua funzione sendToQueue.

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)

Mi piace l'idea di usare un decoratore per questo scopo. Perché lastTimeCalled è un elenco? Inoltre, dubito che questo sarà il lavoro quando più thread stanno chiamando la stessa funzione RateLimited ...
Stephan202

8
È un elenco perché tipi semplici come float sono costanti se catturati da una chiusura. Rendendolo un elenco, l'elenco è costante, ma i suoi contenuti non lo sono. Sì, non è thread-safe ma può essere facilmente risolto con i lucchetti.
Carlos A. Ibarra,

time.clock()non ha una risoluzione sufficiente sul mio sistema, quindi ho adattato il codice e modificato per usarlotime.time()
mtrbean

3
Per limitare la frequenza, sicuramente non si desidera utilizzare time.clock(), che misura il tempo di CPU trascorso. Il tempo della CPU può essere eseguito molto più velocemente o molto più lentamente del tempo "effettivo". Si desidera utilizzare time.time()invece, che misura il tempo di wall (tempo "effettivo").
John Wiseman,

1
A proposito per sistemi di produzione reali: l'implementazione di un limite di velocità con una chiamata sleep () potrebbe non essere una buona idea in quanto bloccherà il thread e quindi impedirà a un altro client di usarlo.
Maresh,

28

Un bucket token è abbastanza semplice da implementare.

Inizia con un secchio con 5 token.

Ogni 5/8 secondi: se il bucket ha meno di 5 token, aggiungine uno.

Ogni volta che si desidera inviare un messaggio: se il bucket ha un token ≥1, estrarre un token e inviare il messaggio. Altrimenti, attendi / rilascia il messaggio / qualunque cosa.

(ovviamente, nel codice reale, useresti un contatore intero invece di token reali e puoi ottimizzare ogni passo di 5/8 memorizzando i timestamp)


Rileggendo nuovamente la domanda, se il limite di velocità viene completamente ripristinato ogni 8 secondi, ecco una modifica:

Inizia con un timestamp, last_sendmolto tempo fa (ad esempio, in epoca). Inoltre, inizia con lo stesso bucket a 5 token.

Colpisci la regola ogni 5/8 secondi.

Ogni volta che invii un messaggio: per prima cosa controlla se last_send≥ 8 secondi fa. In tal caso, riempi il secchio (impostalo su 5 token). In secondo luogo, se ci sono token nel bucket, invia il messaggio (altrimenti, drop / wait / etc.). Terzo, impostato last_sendsu ora.

Dovrebbe funzionare per quello scenario.


In realtà ho scritto un bot IRC usando una strategia come questa (il primo approccio). È in Perl, non in Python, ma ecco un codice per illustrare:

La prima parte qui gestisce l'aggiunta di token al bucket. Puoi vedere l'ottimizzazione dell'aggiunta di token in base al tempo (dalla 2a all'ultima riga) e quindi l'ultima riga blocca il contenuto del bucket al massimo (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 è una struttura di dati che viene passata in giro. Questo è all'interno di un metodo che viene eseguito di routine (calcola quando la prossima volta avrà qualcosa da fare e dorme così a lungo o fino a quando non riceve traffico di rete). La parte successiva del metodo gestisce l'invio. È piuttosto complicato, perché i messaggi hanno priorità associate a loro.

    # 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] = [];

Questa è la prima coda, che viene eseguita indipendentemente da cosa. Anche se la nostra connessione viene uccisa per inondazioni. Utilizzato per cose estremamente importanti, come rispondere al PING del server. Successivamente, il resto delle code:

    # 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}});
                    }
            }
    }

Infine, lo stato del bucket viene salvato nella struttura di dati $ conn (in realtà un po 'più tardi nel metodo; in primo luogo calcola quanto presto avrà più lavoro)

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

Come puoi vedere, il vero codice di gestione della benna è molto piccolo - circa quattro righe. Il resto del codice è la gestione delle code prioritarie. Il bot ha le code prioritarie in modo che, ad esempio, qualcuno che chiacchiera con esso non possa impedirgli di svolgere le sue importanti funzioni di kick / ban.


Mi sto perdendo qualcosa ... sembra che questo ti limiti a 1 messaggio ogni 8 secondi dopo aver superato i primi 5
brividi42

@ chills42: Sì, ho letto la domanda in modo sbagliato ... vedi la seconda metà della risposta.
derobert,

@chills: se last_send è <8 secondi, non aggiungi alcun token al bucket. Se il bucket contiene token, è possibile inviare il messaggio; altrimenti non puoi (hai già inviato 5 messaggi negli ultimi 8 secondi)
derobert

3
Ti sarei grato se la gente downvoting questo per favore spiegherebbe perché ... Vorrei risolvere tutti i problemi che vedi, ma è difficile fare a meno del feedback!
derobert,

10

per bloccare l'elaborazione fino a quando il messaggio non può essere inviato, quindi accodando altri messaggi, la bella soluzione di antti può anche essere modificata in questo modo:

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;

aspetta solo che ci sia abbastanza indennità per inviare il messaggio. per non iniziare con un tasso due volte superiore, l'indennità può anche essere inizializzata con 0.


5
Quando dormi (1-allowance) * (per/rate), devi aggiungere la stessa quantità a last_check.
Alp

2

Mantieni il tempo di invio delle ultime cinque righe. Mantieni i messaggi in coda fino a quando il quinto messaggio più recente (se esiste) è passato almeno 8 secondi (con last_five come matrice di volte):

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()

Non da quando l'hai rivisto non lo sono.
Pesto,

Stai memorizzando cinque timestamp e li stai spostando ripetutamente nella memoria (o eseguendo operazioni con elenchi collegati). Sto memorizzando un contatore intero e un timestamp. E solo facendo l'aritmetica e assegnando.
derobert,

2
Tranne che il mio funzionerà meglio se si tenta di inviare 5 righe ma solo 3 in più sono consentite nel periodo di tempo. Il tuo consentirà di inviare i primi tre e imporrà 8 secondi di attesa prima di inviare 4 e 5. Il mio consentirà di inviare 4 e 5 8 secondi dopo la quarta e la quinta riga più recente.
Pesto,

1
Ma in materia, le prestazioni potrebbero essere migliorate utilizzando un elenco circolare collegato di lunghezza 5, che punta al quinto invio più recente, sovrascrivendolo su un nuovo invio e spostando il puntatore in avanti di uno.
Pesto,

per un bot irc con una velocità limitatore di velocità non è un problema. preferisco la soluzione di elenco in quanto è più leggibile. la risposta bucket che è stata data è confusa a causa della revisione, ma non c'è nulla di sbagliato in essa.
jheriko,

2

Una soluzione è allegare un timestamp a ciascun elemento della coda e scartarlo dopo che sono trascorsi 8 secondi. È possibile eseguire questo controllo ogni volta che si aggiunge la coda.

Funziona solo se limiti la dimensione della coda a 5 e scarti eventuali aggiunte mentre la coda è piena.


1

Se qualcuno è ancora interessato, utilizzo questa semplice classe richiamabile insieme a un archivio di valori chiave LRU a tempo per limitare la frequenza delle richieste per IP. Utilizza un deque, ma può essere riscritto per essere utilizzato con un elenco.

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

Solo un'implementazione Python di un codice dalla risposta accettata.

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

Mi è stato suggerito di suggerirti di aggiungere un esempio di utilizzo del tuo codice .
Luc

0

Cosa ne pensi di questo:

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

Avevo bisogno di una variazione in Scala. Ecco qui:

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)
  }

}

Ecco come può essere utilizzato:

val f = Limiter((5d, 8d), { 
  _: Unit  
    println(System.currentTimeMillis) 
})
while(true){f(())}
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.