Historisch gezien introduceerde Rust Rc (referentietelling) als een prestatiebewuste alternatief voor Arc (atomische referentietelling) voor single-threaded scenario's. Vroege versies van de taal ontbraken deze onderscheid, waardoor alle gedeelde eigendom de kosten van atomische bewerkingen moest betalen. De auto-traits Send en Sync werden ontworpen om thread-safety samenstellend af te dwingen, waardoor de compiler deze eigenschappen automatisch kon afleiden op basis van de samenstellende elementen van een type.
Het kernprobleem ligt in de interne implementatie van Rc, dat een niet-atomische teller gebruikt (meestal gewikkeld in Cell<usize> of UnsafeCell<usize>) om actieve referenties bij te houden. Dit ontwerp gaat uit van single-threaded toegang om de overhead van geheugenschermen te vermijden. Als Rc<T> zou worden toegestaan om Send te implementeren, zou een programma een clone van de pointer naar een andere thread kunnen verplaatsen. Bij destructie of clonering in de nieuwe thread zouden beide threads ongecoördineerde lees-wijzig-schrijf bewerkingen op de referentieteller uitvoeren. Dit vormt een datarace, die mogelijk de teller corrumpeert, wat leidt tot voortijdige vrijgave (use-after-free) of geheugenlekken (double-free).
De oplossing is architecturaal: Rc kiest expliciet voor geen Send en Sync door typen te bevatten die niet thread-safe zijn (of via negatieve implementaties in modern Rust). Dit dwingt ontwikkelaars om Arc<T> te gebruiken voor cross-thread delen, dat AtomicUsize voor zijn tellers gebruikt, wat ervoor zorgt dat incrementeer- en decrementeerbewerkingen atomisch en correct sequentieel zijn over alle CPU-cores. De compiler handhaaft dit onderscheid op het type-niveau, en voorkomt accidenteel delen zonder runtime-controles.
Overweeg een high-performance teksteditor die een groot document in een Abstracte Syntaxboom (AST) parseert. De parser gebruikt Rc<Node> om gedeelde substrings (bijv. identieke identificeerders) door de boom heen te vertegenwoordigen, om het geheugen te optimaliseren tijdens de single-threaded parsingfase. De vereiste ontstaat om semantische validatie te paralleliseren door subbomen naar een threadpool te verdelen.
Het directe probleem is dat compilatie faalt bij het proberen Rc<Node> naar werkthreads te verzenden. Enkele oplossingen werden geëvalueerd:
Globale vervanging met Arc: Alle Rc-instanties vervangen door Arc. Voordelen: Minimale codewijzigingen en onmiddellijke thread-safety. Nadelen: Profileren wees uit dat er een daling van 12-15% in de doorvoer tijdens parsing was vanwege onnodige atomische bewerkingen in het hot path, die de prestatiebudgetten schonden.
Diepe kloning voor verzending: Subbomen serialiseren in Vec<u8>, bytes verzenden en deserialiseren op werkers. Voordelen: Geen onveilige code of architecturale wijzigingen. Nadelen: Hoge latentie en CPU-kosten voor het marshallen van complexe grafstructuren met interne cycli, wat het onuitvoerbaar maakt voor real-time bewerking.
Onveilige pointerextractie: Rc naar een ruwe pointer transmuteren, de pointer verzenden en Rc opnieuw opbouwen aan de ontvanger. Voordelen: Zero-copy overhead. Nadelen: Fundamentaal onveilig; schendt de eigendomsinvariant van Rc (de ontvangende thread kan niet weten of de verzendende thread zijn clones laat vallen), wat onvermijdelijk geheugencorruptie of dangling pointers veroorzaakt.
Channel-gebaseerde taakdistributie: De AST in de hoofdthread behouden en lichte validatietaken (bytebereiken of knoopindices) via crossbeam-kanalen verzenden. Werkers retourneren resultaten zonder de door Rc beheerde geheugen aan te raken. Voordelen: Behoudt de prestaties van Rc voor parsing, elimineert dataraces zonder unsafe, en decouples componenten. Nadelen: Vereist herstructurering van het validatie-algoritme van data-parallel naar taak-parallel.
Het team selecteerde de channel-gebaseerde aanpak. De parser bleef single-threaded en snel, terwijl de validatie lineair groeide met het aantal kernen. Het resultaat was een stabiel systeem zonder unsafe blokken en behoud van prestatiekenmerken.
Waarom blijft Rc<T> !Sync zelfs wanneer het ingepakte type T Sync is, en hoe verschilt dit van de Send-beperking?
Rc<T> kan niet Sync zijn omdat onveranderlijke referenties (&Rc<T>) het mogelijk maken om .clone() aan te roepen, wat de interne niet-atomische referentieteller muteert. Zelfs als T zelf veilig te delen is (Sync), zou het delen van de Rc-wrapper over threads gelijktijdige incremente van de teller vanuit meerdere threads mogelijk maken, wat een datarace zou veroorzaken. De Send-beperking voorkomt het volledig verplaatsen van eigendom naar een andere thread, terwijl de Sync-beperking het zelfs verhindert om referenties over threads te delen. Rc schendt beide principes omdat zijn "read-only" bewerkingen (kloning) daadwerkelijk interne mutatie uitvoeren.
*Hoe beïnvloedt PhantomData<T> de automatische afleiding van Send en Sync voor een aangepaste struct die een ruwe pointer (const T) wikkelt, en waarom is de opname ervan cruciaal?
Zonder PhantomData bevat een struct met *const T geen type-informatie die het aan T koppelt voor de doeleinden van auto-trait afleiding. De compiler veronderstelt conservatief dat de pointer zou kunnen bungelen, willekeurig zou kunnen aliassen of naar thread-lokale gegevens zou kunnen wijzen, en weigert daarom om Send of Sync af te leiden. Door PhantomData<T> op te nemen, geeft de ontwikkelaar aan de compiler aan dat de struct logisch een T bezit. Bijgevolg implementeert de struct automatisch Send als T: Send en Sync als T: Sync, waardoor de samenstellende thread-safety wordt hersteld die essentieel is voor FFI-wrappers of aangepaste slimme pointers.
Onder welke specifieke voorwaarden verliest een trait-object Box<dyn Trait> de Send auto-trait, zelfs wanneer het onderliggende concrete type Send implementeert?
Een trait-object dyn Trait implementeert alleen Send als de trait-definitie expliciet Send als een super-bound vereist (bijv. trait Trait: Send). Wanneer de concrete type in een trait-object wordt geëlimineerd, gooit de compiler alle specifieke type-informatie weg, inclusief auto-trait implementaties. Tenzij de trait zelf de Send-eigenschap garandeert, kan de compiler niet verifiëren of de vtable naar thread-veilige methoden wijst. Dit voorkomt het verzenden van boxed trait-objecten over thread-grenzen tenzij de trait-bound expliciet Send (en Sync) omvat, wat de objectveiligheid effectief beperkt tot thread-veilige implementaties.