Channel9 videon hade jag redan sett, den är verkligen värd att ses. Har också bra koll på hur Clojure implementerat saker som agents ner på detaljnivå och anledningen är att jag hoppades att Clojure var en av de språk/plattformar som kan erbjuda en rättfram lösning på det man kallar C10k (fast 10k klienter är kanske i lägsta laget, denna term myntades för rätt många år sedan).
Clojure är tyvärr inte en sådan plattform och problemet kommer ner till hur I/O hanteras. En sak som Erlang och Go låter dig göra extremt effektivt är hantera I/O men även CPU-bunda problem hanteras effektivt. Clojure (och även Scala) hanterar det senare problemet med bravur men fallerar på det senare i lägen där vi talar om massiva mängder I/O på en server.
Säg att jag vill skriva en server som ska hantera 10k samtida klienter, varje anrop ska kolla vad klienten vill, hämta lite tillstånd/information från en databas (relativt långsamt), göra någon beräkning och presentera ett svar. Hur hanterar du det i Clojure på ett enkelt sätt (eller i Scala, Java, C#, C++ m.fl.)?
I Erlang & Go är det extremt billigt att skapa kontext med egen stack som kan körs oberoende av varandra. Dessa kontext är sedan multiplexade ovanpå en eller flera OS-trådar, typiskt har man en tråd per CPU-kärna i systemet. Programmet blir då
spawn clientHandler(args)
func clientHandler(request, response, args...) {
läst in vad klient vill från 'req'. Detta kan potentiellt blocka om klienten skickar mycket data.
kontakta databas, detta kommer typiskt alltid blocka!
gör beräkning. Detta är typiskt CPU-intensivt
svara klient, detta kan blocka om det är mycket data
}
Att saker blockar eller använder mycket CPU-kraft är inget problem i Erlang/Go då varje sådant kontext kan avbrytas när som helst och blockar man på I/O återanvänds den underliggande tråden.
I Clojure så är det enkelt att lösa den CPU-intensiva biten, rekommendationen om man använder agents är att använda send-off om funktionen man skickar med kan blocka. Det fungerar OK om det handlar om <1000 trådar, men det skalar inte till 10k klienter då ingen idag existerande OS-kärna fixar att effektivt schemalägga 10.000-tals samtidigt körande trådar. Det jag sett av Scala så har man samma problem där + att Scala har inte en lösning på hur man uppdaterar delade saker. Att saker är "immutable" löser läsning, men det löser inte uppdatering av delade strukturer (om två trådar uppdaterar samma struktur samtidigt så är det väldigt sannolikt att de förändringar en tråd gör försvinner om man inte har explicita lås) något Clojure löser väldigt elegant med STM (Software Transaction Memory).
Andra ramverk som node.js, Ruby on Rails, EventMachine (Ruby-ramverk), m.fl. löser alla I/O-delen av C10k problemet med hjälp av att allt är asynkront, men de har ingen bra lösning på hur man kan hantera beräkningsintensiva delar, sådan blockar de "workers" man har, typiskt en per CPU-kärna, så när antal tunga beräkningar överstiger antal kärnor så blockas systemet en stund.
.NET har (nästan) en lösning men den är relativt svår att använda korrekt. Om man kombinerar async/await med TPL som båda bygger på Task<T> så går det (nästan) att lösa C10k problem relativt enkelt där. Problemet är att async/await inte riktigt är designat för denna typ av last p.g.a hur man implementerat möjligheten att återanvända underliggande tråd, det blir allt för mycket "skräp" då varje "await" typiskt kommer blocka och man har tiotusentals eller kanske hundratusentals sådan anrop per sekund i ett system som ska klara C10k problemet, GCn kommer gå på högvarv och då dyker skalbarheten/effektiviteten. Vidare måste utvecklaren förstå vad som är I/O-bundet (ska använda async/await) och CPU-bundet (ska använda TPL), något som ofta kan vara svårt att avgöra och kan även ändras beroende på underliggande HW (ett system med 10Gbit/s länk till ett ARM system kommer vara CPU-bundet till största del, medan en Xeon server med 1Gbit/s länk med stor sannolikhet är I/O-bunden).
Så vitt jag vet är därför Go och Erlang de enda plattformar som löser alla dessa problem på ett väldigt enkelt sätt: man skriver sitt program rakt upp och ner med blockande anrop (vilket är mycket enklare att resonera kring jämfört med asynkrona anrop) och plattformen tar hand om schemaläggning av potentiellt 100.000-tals samtidigt körandes kontext och multiplexar dessa på en rimligt mängd OS-trådar på ett väldigt effektivt sätt när det handlar om I/O (epoll på Linux, I/O-completion ports på Windows).