2015-04-17 5 views
5

Ho un po 'di problemi con la progettazione di uno script bash multiprocesso che va attraverso i siti Web, segue i collegamenti trovati e esegue l'elaborazione su ogni nuova pagina (raccoglie effettivamente e-mail indirizzi ma questo è un dettaglio non importante del problema).Come implementare l'accesso multithreading alla coda basata su file nello script bash

si suppone Lo script di lavorare in questo modo:

  1. scarica una pagina
  2. analizza tutti i collegamenti e li aggiunge alla coda
  3. Fa un po 'di elaborazione senza importanza
  4. Pops un URL da coda e a partire da

Che di per sé sarebbe piuttosto semplice programmare, il problema deriva da due restrizioni e una caratteristica che lo script deve avere.

  • Lo script non deve elaborare un URL due volte
  • Lo script deve essere in grado di elaborare n (fornito come argomento) pagine contemporaneamente
  • Lo script deve essere POSIX complient (con l'eccezione del riccio) - > in modo che nessun serrature fantasia

Ora, sono riuscito a venire con un'implementazione che usa due file per le code, uno dei quali memorizza tutti gli URL che erano già stati elaborati, e un altro gli URL che erano stati trovati, ma non ancora elaborato.

I principali proces genera semplicemente un gruppo di processi figlio che condividono tutti i file di coda e (in un ciclo fino URL-to-be-lavorati-coda è vuota) si apre in alto un URL da URLs-to-be-processed-queue, processo nella pagina, prova per aggiungere ogni nuovo collegamento trovato a URLs-already-processed-queue e se riesce (l'URL non è già lì) aggiungerlo a URLs-to-be-processed-queue pure.

Il problema sta nel fatto che non è possibile (AFAIK) rendere le operazioni del file di coda atomiche e quindi il blocco è necessario. E bloccare in modo POSIX complient è ... terror ... slow terror.

Il modo in cui lo faccio è la seguente:

#Pops first element from a file ($1) and prints it to stdout; if file emepty print out empty return 1 
fuPop(){ 
if [ -s "$1" ]; then 
     sed -nr '1p' "$1" 
     sed -ir '1d' "$1" 
     return 0 
else 
     return 1 
fi 
} 

#Appends line ($1) to a file ($2) and return 0 if it's not in it yet; if it, is just return 1 
fuAppend(){ 
if grep -Fxq "$1" < "$2"; then 
     return 1 
else 
     echo "$1" >> "$2" 
     return 0 
fi 
} 

#There're multiple processes running this function. 
prcsPages(){ 
while [ -s "$todoLinks" ]; do 
     luAckLock "$linksLock" 
     linkToProcess="$(fuPop "$todoLinks")" 
     luUnlock "$linksLock" 

     prcsPage "$linkToProcess" 
     ... 
done 
... 
} 


#The prcsPage downloads it, does some magic and than calls prcsNewLinks and prcsNewEmails that both get list of new emails/new urls in $1 
#$doneEmails, ..., contain file path, $mailLock, ..., contain dir path 

prcsNewEmails(){ 
luAckLock "$mailsLock" 
for newEmail in $1; do 
     if fuAppend "$newEmail" "$doneEmails"; then 
       echo "$newEmail" 
     fi 
done 
luUnlock "$mailsLock" 
} 

prcsNewLinks(){ 
luAckLock "$linksLock" 
for newLink in $1; do 
     if fuAppend "$newLink" "$doneLinks"; then 
       fuAppend "$newLink" "$todoLinks" 
     fi 
done 
luUnlock "$linksLock" 
} 

Il problema è che la mia esecuzione è rallentata (piace molto lento), quasi così lento che non ha senso utilizzare più di 2 10 (riduzione del blocco in attesa aiuta molto) i processi figli. È possibile disabilitare i blocchi (basta commentare i bit luAckLock e luUnlock) e funziona abbastanza bene (e molto più velocemente) ma ci sono delle condizioni di gara ogni tanto con lo seds -i e non sembra giusto.

Il peggiore (secondo me) è bloccaggio in prcsNewLinks quanto richiede parecchio tempo (la maggior parte del tempo-run fondamentalmente) e praticamente impedisce altri processi avvio per elaborare una nuova pagina (in quanto richiede poping nuovo URL da (attualmente bloccata) $todoLinks coda).

Ora la mia domanda è: come fare meglio, più veloce e più bello?

L'intero script è here (contiene alcuni segnali magici, molte uscite di debug e non un buon codice in genere).

BTW: Sì, hai ragione, a fare questo in bash - e per di più in modo conforme a POSIX - è folle! Ma è un compito universitario quindi devo farlo

// Anche se sento che non mi aspetto realmente da me risolvere questi problemi (poiché le condizioni di gara si verificano più frequentemente solo quando si hanno più di 25 thread che probabilmente non sono qualcosa a la persona sana testerebbe).


Note per il codice:

  1. Sì, l'attesa dovrebbe avere (e ha già) un tempo casuale. Il codice condiviso qui era solo una dimostrazione del concetto scritta durante una lezione di analisi reale.
  2. Sì, il numero di debug ouptuts e la loro formattazione è terribile e dovrebbe esserci una funzione di registrazione autonoma. Questo, tuttavia, non è il punto del mio problema.
+0

Il tuo 'per var in $ 1' è un idioma piuttosto sgradevole. Si interrompe quando '" $ 1 "' contiene spazi, dal momento che non lo citate. E non ha senso usare un loop. Basta fare 'var = $ 1'. (la suddivisione di parole e l'espansione globale non avvengono in compiti, quindi non hai bisogno di virgolette, ma non fanno male, quindi non è una cattiva pratica indicare SEMPRE ovunque, anche nei compiti e all'interno di [[$ var == foo]] 'dove non sono necessari.) –

+0

Sì, lo so. Di solito cito tutto, devo averlo dimenticato un po ':). E grazie per la punta che non ha bisogno di loop, è elegante :). – Petrroll

+0

Normalmente si usa un ciclo su "$ @", BTW. Ho dimenticato di dirlo. Anche per funzioni veramente brevi, a volte non mi preoccupo di assegnare i parametri posizionali a 'meaningful_name locale = $ 1'. Nelle funzioni di shell, dovresti usare le variabili 'local' a meno che tu non voglia interagire con le variabili del chiamante con lo stesso nome. –

risposta

2

Prima di tutto, è necessario implementare il proprio spidering HTML/HTTP? Perché non lasciare che wget o curl riceva attraverso un sito per te?

È possibile abusare del filesystem come database e fare in modo che le code siano directory di file a una riga. (o file vuoti in cui il nome file è i dati). Ciò ti darebbe il blocco produttore-consumatore, in cui i produttori toccano un file e i consumatori lo spostano dalla directory in entrata alla directory di elaborazione/completamento.

La bellezza di questo è che più thread che toccano lo stesso file funziona solo. Il risultato desiderato è l'url che appare una volta nella lista "incoming", ed è quello che ottieni quando più thread creano file con lo stesso nome. Dal momento che si desidera la deduplicazione, non è necessario il blocco durante la scrittura nella lista in entrata.

1) scarica una pagina

2) Analizza tutti i collegamenti e li aggiunge alla coda

Per ogni link trovato,

grep -ql "$url" already_checked || : > "incoming/$url" 

o

[[ -e "done/$url" ]] || : > "incoming/$url" 

3) Se alcune elaborazioni poco importante

4) Pops un URL dalla coda e parte da 1)

# mostly untested, you might have to debug/tweak this 
local inc=(incoming/*) 
# edit: this can make threads exit sooner than desired. 
# See the comments for some ideas on how to make threads wait for new work 
while [[ $inc != "incoming/*" ]]; do 
    # $inc is shorthand for "${inc[0]}", the first array entry 
    mv "$inc" "done/" || { rm -f "$inc"; continue; } # another thread got that link, or that url already exists in done/ 
    url=${inc#incoming/} 
    download "$url"; 
    for newurl in $(link_scan "$url"); do 
     [[ -e "done/$newurl" ]] || : > "incoming/$newurl" 
    done 
    process "$url" 
    inc=(incoming/*) 
done 

edit: codifica URL in stringhe che non contengono / è lasciata come esercizio per il lettore. Anche se probabilmente urlencoding / a %2F funzionerebbe abbastanza bene.

Stavo pensando di spostare gli URL a un elenco "di elaborazione" per thread, ma in realtà se non è necessario essere in grado di riprendere dall'interruzione, l'elenco "fatto" può invece essere un elenco "in coda & completato" . Non penso che sia effettivamente mai utile per mv "$url" "threadqueue.$$/ "o qualcosa del genere.

La directory "done /" diventerà piuttosto grande e inizierà a rallentare con forse 10k file, a seconda del filesystem che si utilizza. Probabilmente è più efficiente mantenere la lista "completata" come un file di un url per riga, o un database se c'è un'interfaccia CLI del database che è veloce per i singoli comandi.

Il mantenimento dell'elenco fatto come un file non è male, perché non è mai necessario rimuovere le voci. Probabilmente puoi andare via senza bloccarlo, anche per più processi che lo aggiungono. (Non sono sicuro di cosa succederebbe se il thread B scrivesse i dati su EOF tra il thread A aprendo il file e il thread A eseguendo una scrittura. La posizione del thread A potrebbe essere il vecchio EOF, nel qual caso sovrascrivere l'ingresso del thread B, o peggio sovrascrive solo una parte di esso. Se è necessario il blocco, potrebbe essere utile flock(1). Ottiene un blocco, quindi esegue i comandi passati come argomenti.)

Se non si verificano i file danneggiati dalla mancanza di blocco della scrittura , quindi potrebbe non essere necessario il blocco della scrittura. La voce duplicata occasionale nell'elenco "fatto" sarà un piccolo rallentamento rispetto al dover bloccare per ogni controllo/aggiunta.

Se è necessario evitare in modo rigoroso il download della stessa URL più volte, è necessario che i lettori attenderanno la fine dello scrittore. Se riesci solo a leggere l'elenco di e-mail alla fine, non è un disastro per un lettore leggere una vecchia copia mentre l'elenco viene aggiunto. Quindi gli scrittori devono solo chiudersi a vicenda e i lettori possono solo leggere il file. Se colpiscono EOF prima che uno scrittore riesca a scrivere una nuova voce, allora così sia.

Non sono sicuro se è importante che un thread aggiunga voci all'elenco "completato" prima o dopo averlo rimosso dall'elenco in entrata, a condizione che vengano aggiunti a "completato" prima dell'elaborazione. Stavo pensando che in un modo o nell'altro si potrebbero rendere le gare più propensi a causare voci duplicate e meno probabilità di effettuare download/elaborazioni duplicate, ma non ne sono sicuro.

+0

Sono curioso di sapere come funziona in pratica, dal momento che non ne ho mai provato nessuno. –

+0

Supponiamo che la directory 'incoming' sia vuota e che gli altri processi _n-1_ stiano semplicemente eseguendo la riga' download '$ url "'. Il nostro processo esegue 'inc = (incoming/*)', che si espande in 'incoming/*' ed esce dal ciclo, sebbene l'elaborazione possa essere lungi dall'essere eseguita. Suggerisco di fare il ciclo esterno 'while true' e girare' inc = (incoming/*) 'in' while [(incoming/*) = 'incoming/*']; dormi 1; done'. – Witiko

+0

Buona cattura. Ricordo di aver pensato, hmm, "e se i lettori svuotassero la fila?", E concludendo che non era probabile. Ma in realtà, se inizi con solo l'URL di base nel tuo "in arrivo", un thread lo afferrerà e tutti gli altri usciranno. Derp. Ho pensato di lasciare la condizione del ciclo come 'while true' (che avevo originariamente), e lasciare uscire come esercizio per il lettore. –

Problemi correlati