Geschiedenis van de vraag.
Voor C++11 gebruikten veel std::string-implementaties referentietelling (Copy-on-Write) om tekenreeksgegevens tussen instanties te delen, waardoor de geheugengebruik voor kopieën werd verminderd. Deze benadering veroorzaakte echter problemen met threadveiligheid, waarbij gelijktijdige leesacties de ongeldigmaking van iterators of referenties konden activeren wanneer de interne referentietelling werd gewijzigd. C++11 verbood deze optimalisatie expliciet door te vereisen dat const-lid functies referenties of iterators niet ongeldig maken, wat een nieuwe optimalisatiestrategie vereiste om de prestatiekosten van heapallocaties voor korte tekenreeksen te verlichten.
Het Probleem.
Heapallocatie is duur vanwege synchronisatieoverhead in allocators en problemen met cache-lokalisatie. Voor toepassingen die miljarden kleine tekenreeksen verwerken, zoals JSON-parsers of netwerkprotocolbeheerders, domineert de allocatie van geheugen voor 5-15 tekenreekssequenties de uitvoeringstijd. De uitdaging is het opslaan van kleine tekenreeksen binnen het std::string-object zelf—typisch beperkt tot 32 bytes op 64-bits systemen—zonder de ABI-compatibiliteit te doorbreken of de sterke uitzonderingsveiligheidsgaranties die door de standaard vereist zijn te schenden.
De Oplossing.
Implementaties gebruiken typisch een unie van drie leden voor de opslagbuffer: char* ptr_ voor de heap-geallocateerde array, size_t capacity_, en char local_buffer_[N] voor de ingebedde array. Een discriminator, vaak gecodeerd in het minst significante bit van het size_ lid of met behulp van een specifieke capaciteitswaarde, bepaalt of de tekenreeks zich in "SSO-modus" of "heap-modus" bevindt. Wanneer size() < SSO_CAPACITY, worden de tekens opgeslagen in local_buffer_, met een null-terminator op local_buffer_[size()], waardoor heapallocatie volledig wordt vermeden. Voor grotere tekenreeksen wijst ptr_ naar heapgeheugen, en local_buffer_ wordt hergebruikt om capaciteitsmetadata op te slaan of blijft ongebruikt.
// Conceptuele implementatie (vereenvoudigd) class string { union { struct { char* ptr; size_t size; size_t cap; } heap; // Actief wanneer cap >= SSO_CAP struct { char buffer[15]; // 15 tekens + null-terminator unsigned char size; // Samengepakte metadata, MSB geeft heap aan } sso; // Actief wanneer size < 15 } data; bool is_sso() const { return (data.sso.size & 0x80) == 0; } };
Overweeg een high-frequency trading-applicatie die FIX-protocolberichten verwerkt met tal van kleine tags (bijv. "35=D", "150=2"). De eerste implementatie gebruikte std::string om elke tagwaarde op te slaan, wat resulteerde in miljoenen heapallocaties per seconde en ernstige allocatorcontentie die de marktgegevensfeed verstopte.
Oplossing A: Rauwe pointers in de buffer. Het gebruik van char* pointers in de originele berichtbuffer biedt nulallocatie-overhead en maximale prestaties. Deze benadering introduceert echter gevaarlijke beheersproblemen van levensduur; als de originele buffer opnieuw wordt gebruikt of gedealloceerd terwijl tekenreeksgegevens nog nodig zijn, resulteert dit in gebruik-na-vrij bugs. Bovendien vereist het handmatige tracking van tekenreeks lengtes, wat de complexiteit van de code verhoogt en het potentiële foutenrisico vergroot.
Oplossing B: Aangepaste allocator met geheugenpools. Het implementeren van thread-lokale geheugenpools vermindert allocatorcontentie door allocaties te batchen. Dit voegt echter aanzienlijke templatecomplexiteit toe of vereist polymorfe allocators door de hele codebasis heen. Het elimineert ook de allocatie-overhead niet volledig, maar amortiseert gewoon de kosten over meerdere tekenreeksen.
Oplossing C: std::string_view en SSO. Het gebruik van std::string_view voor read-only verwerking vermijdt kopieën, terwijl het vertrouwen op std::string's automatische SSO voor opgeslagen waarden veiligheid biedt met minimale overhead. Het belangrijkste nadeel is de prestatiekloof wanneer de tekenreeksen de SSO-drempel (15-22 tekens) overschrijden, wat plotseling dure heapallocaties triggert. Bovendien kopieert het verplaatsen van kleine tekenreeksen gegevens in plaats van pointers over te dragen, wat ontwikkelaars kan verrassen die O(1) verplaatsingssemantiek verwachten.
Het team koos voor Oplossing C, waarbij de parser werd herschreven om std::string_view te gebruiken voor tijdelijke referenties en std::string alleen wanneer persistentie vereist was. Dit resulteerde in een vermindering van 95% in heapallocaties voor typische FIX-berichten, waardoor de doorvoer van 50.000 naar 800.000 berichten per seconde verbeterde, terwijl de geheugveiligheid werd behouden.
Waarom presteert het verplaatsen van een korte tekenreeks die intern SSO gebruikt een tekenkopie in plaats van een pointeroverdracht, en hoe beïnvloedt dit de staat van het verplaatste object?
In SSO-modus bevindt de tekenarray zich direct binnen het std::string-object (typisch als een lid van een interne unie). In tegenstelling tot heap-geallocateerde tekenreeksen waar de move constructor eenvoudig de char* pointer overdraagt en de bron op nul zet, vereist het verplaatsen van een SSO-tekenreeks het kopiëren van de tekens van de interne buffer van de bron naar de interne buffer van de bestemming. Dit is noodzakelijk omdat het bronobject zal worden vernietigd, en zijn interne buffer samen met hem; de bestemming kan niet naar geheugen binnen de binnenkort te vernietigen bron wijzen. Als gevolg hiervan heeft het verplaatsen van een kleine tekenreeks een O(N) complexiteit in plaats van O(1), en het verplaatste object blijft in een geldige maar ongepaste staat (niet leeg), met nog steeds zijn oorspronkelijke tekens totdat vernietiging of herassignatie plaatsvindt.
Hoe zorgt std::string ervoor dat de C++11-vereiste dat c_str() en data() null-terminated tekenreeksarrays retourneert bij gebruik van SSO-modus, gegeven dat de interne buffergrootte vast is?
De implementatie zorgt ervoor dat de SSO-buffer altijd één byte groter is dan de maximale SSO-capaciteit (bijv. 16 bytes totaal voor een 15-teken tekenreeks). Wanneer een tekenreeks van lengte N (waar N < SSO_CAPACITY) wordt opgeslagen, schrijft de implementatie de null-terminator op positie N in de lokale buffer. De data() en c_str() methoden retourneren een pointer naar het begin van deze lokale buffer wanneer in SSO-modus, in plaats van de heappointer. Dit garandeert null-terminatie zonder extra allocatie, wat voldoet aan de vereisten van de standaard dat c_str() const char* retourneert naar een null-terminated tekenreeks, en sinds C++11, dat data() ook naar een null-terminated array wijst.
Waarom kan de capacity() van een lege std::string variëren tussen verschillende standaardbibliotheekimplementaties (bijv. 15 vs 22), en welke ABI-implicaties heeft dit voor het mengen van versies van de standaardbibliotheek?
De grootte van de SSO-buffer is een implementatiedetail (libc++ gebruikt typisch 22 tekens op 64-bits systemen door gebruik te maken van uitlijning, terwijl libstdc++ 15 gebruikt). Deze grootte hangt af van hoe de implementatie de grootte/capaciteitsmetadata verpakt naast de lokale buffer binnen de lay-out van het std::string-object (typisch 32 bytes totaal). Omdat dit niet gestandaardiseerd is, resulteert het mengen van binaire bestanden die zijn gecompileerd met verschillende standaardbibliwerkimplementaties (bijv. het doorgeven van een std::string van een GCC-gecompileerde bibliotheek naar een Clang-gecompileerde toepassing) in ongedefinieerd gedrag vanwege incompatibele geheugenschema's. Kandidaten veronderstellen vaak dat std::string een standaard ABI heeft, maar het is een van de minst draagbare types tussen bibliotheekgrenzen.