Porting di sviluppo dell'archivio chiave / valore al C ++ moderno


9

Sto sviluppando un server di database simile a Cassandra.

Lo sviluppo iniziò in C, ma le cose diventarono molto complicate senza lezioni.

Attualmente ho portato tutto in C ++ 11, ma sto ancora imparando il C ++ "moderno" e ho dubbi su molte cose.

Il database funzionerà con coppie chiave / valore. Ogni coppia ha qualche informazione in più - quando viene creata anche quando scadrà (0 se non scade). Ogni coppia è immutabile.

La chiave è stringa C, il valore è nullo *, ma almeno per il momento sto operando con il valore anche come stringa C.

Ci sono IListclassi astratte . È ereditato da tre classi

  • VectorList - C array dinamico - simile a std :: vector, ma utilizza realloc
  • LinkList - fatto per i controlli e il confronto delle prestazioni
  • SkipList - la classe che verrà infine utilizzata.

In futuro potrei fare anche l' Red Blackalbero.

Ciascuno IListcontiene zero o più puntatori a coppie, ordinati per chiave.

Se è IListdiventato troppo lungo, può essere salvato sul disco in un file speciale. Questo file speciale è una specie di read only list.

Se devi cercare una chiave,

  • viene prima IListcercata in memoria ( SkipList, SkipListo LinkList).
  • Quindi la ricerca viene inviata ai file ordinati per data
    (prima il file più recente, il file più vecchio - ultimo).
    Tutti questi file sono mmap-ed in memoria.
  • Se non viene trovato nulla, la chiave non viene trovata.

Non ho dubbi sull'attuazione delle IListcose.


Quello che attualmente mi sta sconcertando è il seguente:

Le coppie hanno dimensioni diverse , sono assegnate da new()e le hanno std::shared_ptrindicate.

class Pair{
public:
    // several methods...
private:
    struct Blob;

    std::shared_ptr<const Blob> _blob;
};

struct Pair::Blob{
    uint64_t    created;
    uint32_t    expires;
    uint32_t    vallen;
    uint16_t    keylen;
    uint8_t     checksum;
    char        buffer[2];
};

La variabile membro "buffer" è quella con dimensioni diverse. Memorizza la chiave + valore.
Ad esempio, se la chiave è di 10 caratteri e il valore è di altri 10 byte, l'intero oggetto sarà sizeof(Pair::Blob) + 20(il buffer ha una dimensione iniziale di 2, a causa di due byte finali nulli)

Lo stesso layout viene utilizzato anche sul disco, quindi posso fare qualcosa del genere:

// get the blob
Pair::Blob *blob = (Pair::Blob *) & mmaped_array[pos];

// create the pair, true makes std::shared_ptr not to delete the memory,
// since it does not own it.
Pair p = Pair(blob, true);

// however if I want the Pair to own the memory,
// I can copy it, but this is slower operation.
Pair p2 = Pair(blob);

Tuttavia, questa diversa dimensione è un problema in molti luoghi con codice C ++.

Ad esempio non posso usare std::make_shared(). Questo è importante per me, perché se avessi coppie 1M, avrei allocazioni 2M.

Dall'altro lato, se faccio "buffer" su un array dinamico (es. Nuovo carattere [123]), perderò il "trucco" di mmap, avrò due dereferenze se voglio controllare la chiave e aggiungerò un singolo puntatore - 8 byte per la classe.

Ho anche cercato di "estrarre" tutti i membri da Pair::Blobdentro Pair, quindi Pair::Blobper essere solo il buffer, ma quando l'ho provato, è stato piuttosto lento, probabilmente a causa della copia dei dati dell'oggetto.

Un'altra modifica che sto pensando è anche quella di rimuovere la Pairclasse e sostituirla con std::shared_ptre di "rimandare" tutti i metodi a Pair::Blob, ma questo non mi aiuterà con la Pair::Blobclasse di dimensioni variabili .

Mi chiedo come posso migliorare la progettazione degli oggetti per essere più amichevole con il C ++.


Il codice sorgente completo è qui:
https://github.com/nmmmnu/HM3


2
Perché non usi std::mapo std::unordered_map? Perché i valori (associati alle chiavi) sono alcuni void*? Probabilmente avresti bisogno di distruggerli ad un certo punto; come quando? Perché non usi i template?
Basile Starynkevitch l'

Non uso std :: map, perché credo (o almeno provo) a fare qualcosa di meglio di std :: map per il caso attuale. Ma sì, sto pensando a un certo punto di concludere lo std :: map e di controllarne le prestazioni anche come IList.
Nick,

La deallocazione e la chiamata dei direttori viene eseguita dove si trova l'elemento IList::removeo quando IList viene distrutto. Ci vuole molto tempo, ma lo farò in thread separati. Sarà facile perché IList lo sarà std::unique_ptr<IList>comunque. così sarò in grado di "cambiarlo" con un nuovo elenco e mantenere il vecchio oggetto da qualche parte dove posso chiamare d-tor.
Nick,

Ho provato i modelli. Non sono la soluzione migliore qui, perché questa non è una libreria utente, la chiave è sempre C stringe i dati sono sempre dei buffer void *o char *, quindi è possibile passare array di caratteri. Puoi trovare simili in rediso memcached. Ad un certo punto potrei decidere di usare std::stringo correggere un array di caratteri per la chiave, ma sottolineo che sarà ancora la stringa C.
Nick,

6
Invece di aggiungere 4 commenti, dovresti modificare la tua domanda
Basile Starynkevitch l'

Risposte:


3

L'approccio che consiglierei è quello di concentrarsi sull'interfaccia del tuo archivio di valori-chiave, in modo da renderlo il più pulito possibile e il più non restrittivo possibile, nel senso che dovrebbe consentire la massima libertà ai chiamanti, ma anche la massima libertà di scelta come implementarlo.

Quindi, consiglierei di fornire un'implementazione il più semplice possibile e la più pulita possibile, senza alcun problema di prestazioni. A me sembra che unordered_mapdovrebbe essere la tua prima scelta, o forse mapse un qualche tipo di ordinamento delle chiavi deve essere esposto dall'interfaccia.

Quindi, prima farlo funzionare in modo pulito e minimale; quindi, utilizzalo in una vera applicazione; nel fare ciò, troverai quali problemi devi affrontare sull'interfaccia; quindi, vai avanti e affrontali. La maggior parte delle probabilità è che, a seguito della modifica dell'interfaccia, dovrai riscrivere grandi parti dell'implementazione, quindi ogni volta che hai già investito sulla prima iterazione dell'implementazione oltre il minimo tempo necessario per ottenerlo solo a malapena il lavoro è tempo perso.

Quindi, profilalo e vedi cosa deve essere migliorato nell'implementazione, senza alterare l'interfaccia. Oppure potresti avere le tue idee su come migliorare l'implementazione, prima ancora di creare un profilo. Va bene, ma non c'è ancora motivo di lavorare su queste idee in un momento precedente.

Dici che speri di fare meglio di map; ci sono due cose che si possono dire al riguardo:

a) probabilmente non lo farai;

b) evitare l'ottimizzazione prematura a tutti i costi.

Per quanto riguarda l'implementazione, il problema principale sembra essere l'allocazione di memoria, poiché sembra che ti preoccupi di come strutturare il tuo progetto per aggirare i problemi che prevedi di avere riguardo all'allocazione di memoria. Il modo migliore per affrontare i problemi di allocazione della memoria in C ++ è implementare un'adeguata gestione dell'allocazione della memoria, non torcendo e piegando il design attorno a loro. Dovresti considerarti fortunato di utilizzare C ++, che ti consente di gestire autonomamente l'allocazione della memoria, al contrario di lingue come Java e C #, dove sei praticamente bloccato da ciò che il runtime linguistico ha da offrire.

Esistono vari modi per gestire la memoria in C ++ e la possibilità di sovraccaricare l' newoperatore può tornare utile. Un allocatore di memoria semplicistico per il tuo progetto avrebbe preallocato una vasta gamma di byte e usandolo come un heap. ( byte* heap.) Si avrebbe un firstFreeByteindice, inizializzato su zero, che indica il primo byte libero nell'heap. Quando Narriva una richiesta di byte, si restituisce l'indirizzo heap + firstFreeBytee si aggiunge Na firstFreeByte. Pertanto, l'allocazione della memoria diventa così veloce ed efficiente che non diventa praticamente un problema.

Naturalmente, la preallocazione di tutta la tua memoria potrebbe non essere una buona idea, quindi potresti dover rompere il tuo mucchio in banche che sono allocate su richiesta e continuare a servire le richieste di allocazione dalla banca in qualsiasi momento.

Poiché i tuoi dati sono immutabili, questa è una buona soluzione. Ti consente di abbandonare l'idea di oggetti a lunghezza variabile e di avere ciascuno Pairun puntatore ai suoi dati come dovrebbe, poiché l'allocazione di memoria aggiuntiva per i dati non costa praticamente nulla.

Se vuoi essere in grado di scartare oggetti dall'heap, in modo da poter recuperare la loro memoria, le cose diventano più complicate: dovrai usare non puntatori, ma puntatori a puntatori, in modo da poter sempre spostare oggetti in giro nei cumuli in modo da recuperare lo spazio degli oggetti eliminati. Tutto diventa un po 'più lento a causa dell'ulteriore riferimento indiretto, ma tutto è ancora velocissimo rispetto all'utilizzo delle routine di allocazione della memoria della libreria di runtime standard.

Ovviamente, tutto ciò è davvero inutile da considerare se non si crea prima una versione funzionante, semplice e minimale, del proprio database, e non lo si utilizza in una vera applicazione.

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.