Funktioner i Q- och KDB+-språket med exemplet på en realtidstjänst

Du kan läsa om vad KDB+-databasen är, programmeringsspråket Q och vilka deras styrkor och svagheter är i min tidigare artikel. artikeln och kortfattat i inledningen. I artikeln kommer vi att implementera en tjänst i Q som kommer att bearbeta den inkommande dataströmmen och beräkna olika aggregeringsfunktioner varje minut i "realtidsläge" (dvs. den kommer att ha tid att beräkna allt innan nästa datadel). Huvudfunktionen hos Q är att det är ett vektorspråk som låter dig arbeta inte med enskilda objekt, utan med deras arrayer, arrayer av arrayer och andra komplexa objekt. Språk som Q och relaterade språk K, J, APL är kända för sin korthet. Ofta kan ett program som tar upp flera skärmar med kod i ett välbekant språk som Java skrivas på några rader. Det är precis vad jag vill demonstrera i den här artikeln.

Funktioner i Q- och KDB+-språket med exemplet på en realtidstjänst

Inledning

KDB+ är en kolumndatabas utformad för mycket stora datamängder ordnade på ett visst sätt (främst efter tid). Den används främst inom finansiella organisationer - banker, investeringsfonder, försäkringsbolag. Q-språket är KDB+ interna språk, vilket gör att du kan arbeta effektivt med dessa data. Ideologin bakom Q är korthet och effektivitet, medan tydlighet offras. Detta motiveras av det faktum att ett vektorspråk kommer att vara svårt att uppfatta i vilket fall som helst, och kortheten och fylligheten i posten gör att du kan se en mycket större del av programmet på en skärm, vilket i slutändan gör det lättare att förstå.

I den här artikeln ska vi implementera ett fullfjädrat program i Q och du kanske vill prova det. För detta behöver du själva Q. Du kan ladda ner en gratis 32-bitarsversion från kx-företagets webbplats – www.kx.comDär, om du är intresserad, hittar du även referensinformation om Q, en bok Q för dödliga och diverse artiklar om detta ämne.

Problem uttalande

Det finns en källa som skickar en datatabell var 25:e millisekund. Eftersom KDB+ främst används inom finans antar vi att detta är en tabell över affärer, som har följande kolumner: tid (tid i millisekunder), sym (företagssymbol på börsen – IBM, AAPL,…), pris (priset som aktierna köptes till), storlek (transaktionens storlek). Intervallet på 25 millisekunder väljs godtyckligt, det är inte för litet och inte för stort. Dess närvaro innebär att data anländer till tjänsten redan buffrad. Det skulle vara enkelt att implementera buffring på tjänstesidan, inklusive dynamisk buffring beroende på den aktuella belastningen, men för enkelhetens skull stannar vi vid ett fast intervall.

Tjänsten måste beräkna en uppsättning aggregeringsfunktioner per minut för varje inkommande symbol från sym-kolumnen - maxpris, medelpris, summastorlek etc. användbar information. För enkelhetens skull antar vi att alla funktioner kan beräknas stegvis, d.v.s. för att få ett nytt värde räcker det att känna till två tal - det gamla och det inkommande värdet. Till exempel har max-, medel- och summafunktionerna denna egenskap, men medianfunktionen har inte det.

Vi antar också att den inkommande dataströmmen är ordnad efter tid. Detta gör att vi bara kan arbeta med sista minuten. I praktiken räcker det att kunna arbeta med aktuella och tidigare minuter ifall vissa uppdateringar är sena. För enkelhetens skull kommer vi inte att beakta detta fall.

Aggregeringsfunktioner

Nedan följer de nödvändiga aggregeringsfunktionerna. Jag använde så många som möjligt av dem för att öka belastningen på tjänsten:

  • hög – maxpris – maxpris per minut.
  • lågt – minpris – lägsta pris per minut.
  • firstPrice – första pris – första pris per minut.
  • lastPrice – sista pris – det sista priset per minut.
  • firstSize – första storlek – den första transaktionsstorleken per minut.
  • lastSize – sista storlek — den senaste transaktionsstorleken per minut.
  • numTrades – antal i – antal affärer per minut.
  • volym – summastorlek – summan av transaktionsstorlekar per minut.
  • pvolym – summapris – summa av priser per minut, krävs för medelpris.
  • omsättning – summapris*storlek – total volym transaktioner per minut.
  • snittpris – pvolym%antalaffärer – genomsnittspris per minut.
  • snittstorlek – volym%antalAffärer – genomsnittlig handelsstorlek per minut.
  • vwap – omsättning%volym – genomsnittligt pris per minut viktat med transaktionens storlek.
  • cumVolume – summavolym – ackumulerad volym av transaktioner för all tid.

Låt oss diskutera en icke-uppenbar punkt direkt – hur man initierar dessa kolumner för första gången och för varje efterföljande minut. Vissa kolumner av typen firstPrice måste initieras med ett nullvärde varje gång, deras värde är odefinierat. Andra av volymtypen måste alltid sättas till 0. Det finns också kolumner som kräver en kombinerad metod – till exempel måste cumVolume kopieras från föregående minut och sättas till 0 för den första. Låt oss ställa in alla dessa parametrar med hjälp av ordboksdatatypen (analogt med en post):

// list ! list – создать словарь, 0n – float null, 0N – long null, `sym – тип символ, `sym1`sym2 – список символов
initWith:`sym`time`high`low`firstPrice`lastPrice`firstSize`lastSize`numTrades`volume`pvolume`turnover`avgPrice`avgSize`vwap`cumVolume!(`;00:00;0n;0n;0n;0n;0N;0N;0;0;0.0;0.0;0n;0n;0n;0);
aggCols:reverse key[initWith] except `sym`time; // список всех вычисляемых колонок, reverse объяснен ниже

Jag lade till sym och time i ordboken för enkelhetens skull, nu är initWith en färdig rad från den slutliga aggregerade tabellen, där det återstår att ange rätt sym och tid. Du kan använda den för att lägga till nya rader i tabellen.

Vi behöver aggCols när vi skapar aggregeringsfunktionen. Listan måste inverteras på grund av särdragen i ordningen för uttrycksutvärderingen i Q (från höger till vänster). Målet är att säkerställa att utvärderingen går från high till cumVolume, eftersom vissa kolumner är beroende av de föregående.

Kolumner som behöver kopieras till den nya minuten från den föregående, sym-kolumnen läggs till för enkelhetens skull:

rollColumns:`sym`cumVolume;

Nu ska vi dela in kolumnerna i grupper efter hur de ska uppdateras. Det finns tre typer:

  1. Ackumulatorer (volym, omsättning,..) – vi måste lägga till det inkommande värdet till det föregående.
  2. Med en speciell punkt (hög, låg, ..) - det första värdet i minuten tas från inkommande data, resten beräknas med hjälp av funktionen.
  3. Resten beräknas alltid med hjälp av funktionen.

Låt oss definiera variabler för dessa klasser:

accumulatorCols:`numTrades`volume`pvolume`turnover;
specialCols:`high`low`firstPrice`firstSize;

Beräkningsordning

Vi kommer att uppdatera den aggregerade tabellen i två steg. För effektivitetens skull kommer vi först att komprimera indatatabellen så att det finns en rad för varje symbol och minut. Det faktum att alla våra funktioner är inkrementella och associativa garanterar att resultatet inte kommer att ändras från detta ytterligare steg. Vi skulle kunna komprimera tabellen med hjälp av en select-funktion:

select high:max price, low:min price … by sym,time.minute from table

Den här metoden har en nackdel - uppsättningen beräknade kolumner är fördefinierad. Lyckligtvis är select i Q också implementerad som en funktion där du kan ersätta dynamiskt skapade argument:

?[table;whereClause;byClause;selectClause]

Jag kommer inte att beskriva argumentens format i detalj, i vårt fall kommer endast by- och select-uttrycken att vara icke-triviala och de måste vara ordböcker på formen columns!expressions. Således kan komprimeringsfunktionen definieras enligt följande:

selExpression:`high`low`firstPrice`lastPrice`firstSize`lastSize`numTrades`volume`pvolume`turnover!parse each ("max price";"min price";"first price";"last price";"first size";"last size";"count i";"sum size";"sum price";"sum price*size"); // each это функция map в Q для одного списка
preprocess:?[;();`sym`time!`sym`time.minute;selExpression];

För tydlighetens skull använde jag parse-funktionen, som omvandlar en sträng med ett Q-uttryck till ett värde som kan skickas till eval-funktionen och som krävs i select-funktionen. Observera också att preprocess definieras som en projektion (dvs. en funktion med delvis specificerade argument) av select-funktionen, ett argument (tabellen) saknas. Om vi ​​tillämpar preprocess på tabellen får vi en komprimerad tabell.

Det andra steget är att uppdatera den aggregerade tabellen. Låt oss först skriva algoritmen i pseudokod:

for each sym in inputTable
  idx: row index in agg table for sym+currentTime;
  aggTable[idx;`high]: aggTable[idx;`high] | inputTable[sym;`high];
  aggTable[idx;`volume]: aggTable[idx;`volume] + inputTable[sym;`volume];
  …

I Q är det vanligt att använda mappa/reducera-funktioner istället för cykler. Men eftersom Q är ett vektorspråk och vi säkert kan tillämpa alla operationer på alla symboler samtidigt, kan vi, som en första approximation, klara oss utan en cykel alls och utföra operationer på alla symboler samtidigt:

idx:calcIdx inputTable;
row:aggTable idx;
aggTable[idx;`high]: row[`high] | inputTable`high;
aggTable[idx;`volume]: row[`volume] + inputTable`volume;
…

Men vi kan gå längre, Q har en unik och extremt kraftfull operator – den generaliserade tilldelningsoperatorn. Den låter dig ändra en uppsättning värden i en komplex datastruktur med hjälp av en lista med index, funktioner och argument. I vårt fall ser det ut så här:

idx:calcIdx inputTable;
rows:aggTable idx;
// .[target;(idx0;idx1;..);function;argument] ~ target[idx 0;idx 1;…]: function[target[idx 0;idx 1;…];argument], в нашем случае функция – это присваивание
.[aggTable;(idx;aggCols);:;flip (row[`high] | inputTable`high;row[`volume] + inputTable`volume;…)];

Tyvärr kräver tilldelningen till tabellen en lista med rader, inte kolumner, och kräver att matrisen transponeras (lista med kolumner till lista med rader) med hjälp av flip-funktionen. Detta är dyrt för en stor tabell, så istället tillämpar vi en generisk tilldelning till varje kolumn individuellt med hjälp av map-funktionen (som ser ut som en apostrof):

.[aggTable;;:;]'[(idx;)each aggCols; (row[`high] | inputTable`high;row[`volume] + inputTable`volume;…)];

Vi använder funktionsprojektion igen. Observera också att i Q är det också en funktion att skapa en lista, och vi kan anropa den med each(map) för att få en lista med listor.

För att undvika en fast uppsättning beräknade kolumner, låt oss skapa uttrycket ovan dynamiskt. Definiera först funktioner för att beräkna varje kolumn, med hjälp av variablerna row och inp för att referera till aggregerings- och indata:

aggExpression:`high`low`firstPrice`lastPrice`firstSize`lastSize`avgPrice`avgSize`vwap`cumVolume!
 ("row[`high]|inp`high";"row[`low]&inp`low";"row`firstPrice";"inp`lastPrice";"row`firstSize";"inp`lastSize";"pvolume%numTrades";"volume%numTrades";"turnover%volume";"row[`cumVolume]+inp`volume");

Vissa kolumner är speciella, deras första värde ska inte beräknas av funktionen. Vi kan avgöra att det är det första med hjälp av kolumnen row[`numTrades] – om den är 0, då är värdet det första. Q har en select-funktion – ?[Boolean list;list1;list2] – som väljer ett värde från lista 1 eller 2 beroende på villkoret i det första argumentet:

// high -> ?[isFirst;inp`high;row[`high]|inp`high]
// @ - тоже обобщенное присваивание для случая когда индекс неглубокий
@[`aggExpression;specialCols;{[x;y]"?[isFirst;inp`",y,";",x,"]"};string specialCols];

Här har jag anropat en generisk tilldelning med min funktion (uttrycket inom klammerparenteser). Den får det aktuella värdet (det första argumentet) och ett ytterligare argument som jag skickar i den fjärde parametern.

Låt oss lägga till batterihögtalare separat, eftersom funktionen är densamma för dem:

// volume -> row[`volume]+inp`volume
aggExpression[accumulatorCols]:{"row[`",x,"]+inp`",x } each string accumulatorCols;

Detta är en normal tilldelning enligt Q-standarder, bara det att jag tilldelar en lista med värden samtidigt. Slutligen, låt oss skapa huvudfunktionen:

// ":",/:aggExprs ~ map[{":",x};aggExpr] => ":row[`high]|inp`high" присвоим вычисленное значение переменной, потому что некоторые колонки зависят от уже вычисленных значений
// string[cols],'exprs ~ map[,;string[cols];exprs] => "high:row[`high]|inp`high" завершим создание присваивания. ,’ расшифровывается как map[concat]
// ";" sv exprs – String from Vector (sv), соединяет список строк вставляя “;” посредине
updateAgg:value "{[aggTable;idx;inp] row:aggTable idx; isFirst_0=row`numTrades; .[aggTable;;:;]'[(idx;)each aggCols;(",(";"sv string[aggCols],'":",/:aggExpression aggCols),")]}";

Med det här uttrycket skapar jag dynamiskt en funktion från en sträng som innehåller uttrycket jag gav ovan. Resultatet kommer att se ut så här:

{[aggTable;idx;inp] rows:aggTable idx; isFirst_0=row`numTrades; .[aggTable;;:;]'[(idx;)each aggCols ;(cumVolume:row[`cumVolume]+inp`cumVolume;… ; high:?[isFirst;inp`high;row[`high]|inp`high])]}

Utvärderingsordningen för kolumnerna är inverterad, eftersom utvärderingsordningen i Q är från höger till vänster.

Nu har vi två huvudfunktioner som behövs för databehandling, allt som återstår är att lägga till lite infrastruktur och tjänsten är klar.

Sista stegen

Vi har funktionerna preprocess och updateAgg som gör allt arbete. Men vi måste fortfarande säkerställa korrekt övergång genom minuter och beräkna indexen för aggregering. Låt oss först definiera init-funktionen:

init:{
  tradeAgg:: 0#enlist[initWith]; // создаем пустую типизированную таблицу, enlist превращает словарь в таблицу, а 0# означает взять 0 элементов из нее
  currTime::00:00; // начнем с 0, :: означает, что присваивание в глобальную переменную
  currSyms::`u#`symbol$(); // `u# - превращает список в дерево, для ускорения поиска элементов
  offset::0; // индекс в tradeAgg, где начинается текущая минута 
  rollCache:: `sym xkey update `u#sym from rollColumns#tradeAgg; // кэш для последних значений roll колонок, таблица с ключом sym
 }

Vi kommer också att definiera en roll-funktion som ändrar den aktuella minuten:

roll:{[tm]
  if[currTime>tm; :init[]]; // если перевалили за полночь, то просто вызовем init
  rollCache,::offset _ rollColumns#tradeAgg; // обновим кэш – взять roll колонки из aggTable, обрезать, вставить в rollCache
  offset::count tradeAgg;
  currSyms::`u#`$();
 }

Vi behöver en funktion för att lägga till nya symboler:

addSyms:{[syms]
  currSyms,::syms; // добавим в список известных
  // добавим в таблицу sym, time и rollColumns воспользовавшись обобщенным присваиванием.
  // Функция ^ подставляет значения по умолчанию для roll колонок, если символа нет в кэше. value flip table возвращает список колонок в таблице.
  `tradeAgg upsert @[count[syms]#enlist initWith;`sym`time,cols rc;:;(syms;currTime), (initWith cols rc)^value flip rc:rollCache ([] sym: syms)];
 }

Och slutligen, funktionen upd (det traditionella namnet på denna funktion för Q-tjänster), som anropas av klienten för att lägga till data:

upd:{[tblName;data] // tblName нам не нужно, но обычно сервис обрабатывает несколько таблиц 
  tm:exec distinct time from data:() xkey preprocess data; // preprocess & calc time
  updMinute[data] each tm; // добавим данные для каждой минуты
};
updMinute:{[data;tm]
  if[tm<>currTime; roll tm; currTime::tm]; // поменяем минуту, если необходимо
  data:select from data where time=tm; // фильтрация
  if[count msyms:syms where not (syms:data`sym)in currSyms; addSyms msyms]; // новые символы
  updateAgg[`tradeAgg;offset+currSyms?syms;data]; // обновим агрегированную таблицу. Функция ? ищет индекс элементов списка справа в списке слева.
 };

Det var allt. Här är hela koden för vår tjänst, som utlovat, bara några rader:

initWith:`sym`time`high`low`firstPrice`lastPrice`firstSize`lastSize`numTrades`volume`pvolume`turnover`avgPrice`avgSize`vwap`cumVolume!(`;00:00;0n;0n;0n;0n;0N;0N;0;0;0.0;0.0;0n;0n;0n;0);
aggCols:reverse key[initWith] except `sym`time;
rollColumns:`sym`cumVolume;

accumulatorCols:`numTrades`volume`pvolume`turnover;
specialCols:`high`low`firstPrice`firstSize;

selExpression:`high`low`firstPrice`lastPrice`firstSize`lastSize`numTrades`volume`pvolume`turnover!parse each ("max price";"min price";"first price";"last price";"first size";"last size";"count i";"sum size";"sum price";"sum price*size");
preprocess:?[;();`sym`time!`sym`time.minute;selExpression];

aggExpression:`high`low`firstPrice`lastPrice`firstSize`lastSize`avgPrice`avgSize`vwap`cumVolume!("row[`high]|inp`high";"row[`low]&inp`low";"row`firstPrice";"inp`lastPrice";"row`firstSize";"inp`lastSize";"pvolume%numTrades";"volume%numTrades";"turnover%volume";"row[`cumVolume]+inp`volume");
@[`aggExpression;specialCols;{"?[isFirst;inp`",y,";",x,"]"};string specialCols];
aggExpression[accumulatorCols]:{"row[`",x,"]+inp`",x } each string accumulatorCols;
updateAgg:value "{[aggTable;idx;inp] row:aggTable idx; isFirst_0=row`numTrades; .[aggTable;;:;]'[(idx;)each aggCols;(",(";"sv string[aggCols],'":",/:aggExpression aggCols),")]}"; / '

init:{
  tradeAgg::0#enlist[initWith];
  currTime::00:00;
  currSyms::`u#`symbol$();
  offset::0;
  rollCache:: `sym xkey update `u#sym from rollColumns#tradeAgg;
 };
roll:{[tm]
  if[currTime>tm; :init[]];
  rollCache,::offset _ rollColumns#tradeAgg;
  offset::count tradeAgg;
  currSyms::`u#`$();
 };
addSyms:{[syms]
  currSyms,::syms;
  `tradeAgg upsert @[count[syms]#enlist initWith;`sym`time,cols rc;:;(syms;currTime),(initWith cols rc)^value flip rc:rollCache ([] sym: syms)];
 };

upd:{[tblName;data] updMinute[data] each exec distinct time from data:() xkey preprocess data};
updMinute:{[data;tm]
  if[tm<>currTime; roll tm; currTime::tm];
  data:select from data where time=tm;
  if[count msyms:syms where not (syms:data`sym)in currSyms; addSyms msyms];
  updateAgg[`tradeAgg;offset+currSyms?syms;data];
 };

testning

Låt oss kontrollera tjänstens prestanda. För att göra detta, kör den i en separat process (placera koden i service.q-filen) och anropa init-funktionen:

q service.q –p 5566

q)init[]

I en annan konsol, starta en andra Q-process och anslut till den första:

h:hopen `:host:5566
h:hopen 5566 // если оба на одном хосте

Först, låt oss skapa en lista med symboler - 10000 XNUMX bitar och lägga till en funktion för att skapa en slumpmässig tabell. I den andra konsolen:

syms:`IBM`AAPL`GOOG,-9997?`8
rnd:{[n;t] ([] sym:n?syms; time:t+asc n#til 25; price:n?10f; size:n?10)}

Jag lade till tre reella symboler i listan för att göra det lättare att hitta dem i tabellen. Funktionen rnd skapar en slumpmässig tabell med n rader, där tiden ändras från t till t+25 millisekunder.

Nu kan du försöka skicka data till tjänsten (låt oss lägga till de första tio timmarna):

{h (`upd;`trade;rnd[10000;x])} each `time$00:00 + til 60*10

Du kan kontrollera i tjänsten att tabellen har uppdaterats:

c 25 200
select from tradeAgg where sym=`AAPL
-20#select from tradeAgg where sym=`AAPL

Resultat:

sym|time|high|low|firstPrice|lastPrice|firstSize|lastSize|numTrades|volume|pvolume|turnover|avgPrice|avgSize|vwap|cumVolume
--|--|--|--|--|--------------------------------
AAPL|09:27|9.258904|9.258904|9.258904|9.258904|8|8|1|8|9.258904|74.07123|9.258904|8|9.258904|2888
AAPL|09:28|9.068162|9.068162|9.068162|9.068162|7|7|1|7|9.068162|63.47713|9.068162|7|9.068162|2895
AAPL|09:31|4.680449|0.2011121|1.620827|0.2011121|1|5|4|14|9.569556|36.84342|2.392389|3.5|2.631673|2909
AAPL|09:33|2.812535|2.812535|2.812535|2.812535|6|6|1|6|2.812535|16.87521|2.812535|6|2.812535|2915
AAPL|09:34|5.099025|5.099025|5.099025|5.099025|4|4|1|4|5.099025|20.3961|5.099025|4|5.099025|2919

Nu ska vi göra några belastningstester för att se hur mycket data tjänsten kan bearbeta per minut. Kom ihåg att vi har satt uppdateringsintervallet till 25 millisekunder. Följaktligen bör tjänsten (i genomsnitt) hålla sig till minst 20 millisekunder per uppdatering för att ge användarna tid att begära data. Ange följande i den andra processen:

tm:10:00:00.000
stressTest:{[n] 1 string[tm]," "; times,::h ({st:.z.T; upd[`trade;x]; .z.T-st};rnd[n;tm]); tm+:25}
start:{[n] times::(); do[4800;stressTest[n]]; -1 " "; `min`avg`med`max!(min times;avg times;med times;max times)}

4800 är två minuter. Du kan prova att köra den först i 1000 rader var 25:e millisekund:

start 1000

I mitt fall blir resultatet ungefär ett par millisekunder per uppdatering. Så jag kommer omedelbart att öka antalet rader till 10.000 XNUMX:

start 10000

Resultat:

min| 00:00:00.004
avg| 9.191458
med| 9f
max| 00:00:00.030

Återigen, inget speciellt, men det är 24 miljoner linjer per minut, 400 tusen per sekund. Uppdateringen saktade bara ner mer än 25 millisekunder 5 gånger, förmodligen när minuten ändrades. Låt oss öka det till 100.000 XNUMX:

start 100000

Resultat:

min| 00:00:00.013
avg| 25.11083
med| 24f
max| 00:00:00.108
q)sum times
00:02:00.532

Som vi kan se klarar tjänsten sig knappt, men den lyckas ändå hålla sig flytande. En sådan datamängd (240 miljoner linjer per minut) är extremt stor; i sådana fall är det vanligt att starta flera kloner (eller till och med dussintals kloner) av tjänsten, där var och en bara bearbetar en del av tecknen. Resultatet är dock imponerande för ett tolkat språk som främst är inriktat på att lagra data.

Du kanske undrar varför tiden växer ickelinjärt med storleken på varje uppdatering. Anledningen är att krympfunktionen egentligen är en C-funktion, som fungerar mycket mer effektivt än updateAgg. Från en viss uppdateringsstorlek (cirka 10.000 30) når updateAgg sitt tak och dess exekveringstid beror sedan inte på uppdateringsstorleken. Det är tack vare det preliminära Q-steget som tjänsten kan bearbeta sådana datamängder. Detta betonar hur viktigt det är att välja rätt algoritm när man arbetar med big data. En annan punkt är korrekt lagring av data i minnet. Om data inte lagrades kolumnformat eller inte ordnades efter tid, skulle vi bekanta oss med något som kallas TLB-cachemiss - avsaknaden av en minnesidadress i processorns adresscache. Adresssökningen tar cirka XNUMX gånger längre tid vid fel och vid spridd data kan tjänsten sakta ner flera gånger.

Slutsats

I den här artikeln visade jag att KDB+- och Q-databaserna är lämpliga inte bara för att lagra stora datamängder och enkelt komma åt dem via select, utan också för att skapa databehandlingstjänster som kan bearbeta hundratals miljoner rader/gigabyte data även i en enda Q-process. Själva Q-språket möjliggör extremt kort och effektiv implementering av databehandlingsalgoritmer tack vare dess vektorkaraktär, inbyggda SQL-dialekttolk och en mycket framgångsrik uppsättning biblioteksfunktioner.

Jag vill påpeka att ovanstående bara är en del av Q:s möjligheter, det har andra unika funktioner. Till exempel ett extremt enkelt IPC-protokoll som suddar ut gränsen mellan enskilda Q-processer och gör det möjligt att kombinera hundratals av dessa processer till ett enda nätverk, som kan placeras på dussintals servrar i olika delar av världen.

Källa: will.com

Köp pålitlig hosting för webbplatser med DDoS-skydd, VPS VDS-servrar 🔥 Köp pålitlig webbhotell med DDoS-skydd, VPS VDS-servrar | ProHoster