Как быстро работают сообщения веб-работника?
Я задавался вопросом, может ли передача в или из веб-работника стать узким местом. Должны ли мы публиковать сообщения так же, как мы инициируем какие-либо события, или мы должны позаботиться и постараться максимально ограничить общение между ними?
Давайте иметь пример. Если у меня есть огромный массив, который создается динамически (например, массив точек контакта, поступающих из mousemove
или же touchmove
для распознавателя жестов), эффективнее ли передавать данные итеративно - т.е. отправлять каждый элемент, как только мы его получим, и позволить работнику хранить их на своей стороне - или лучше хранить их в главном потоке и отправлять все данные сразу в конце, в частности, когда нельзя использовать переносимый объект?
2 ответа
Ну, вы можете буферизовать данные в Uint16Array
1 Затем вы можете сделать небольшой трюк и переместить данные вместо копирования. Смотрите это демо на MDN для ознакомления.
1: должно быть достаточно для экранов размером менее 16x16 метров при плотности пикселей 0,25 пикселей на миллиметр, что, как я считаю, является большинством экранов в мире
1. Как быстро?
Сначала на ваш вопрос, давайте проверим скорость веб-работников.
Я создал этот тестовый фрагмент, который пытается измерить фактическую скорость рабочих. Но попытки здесь важны. По правде говоря, я понял, что только надежный способ измерения времени будет влиять на время, так же как и в современных физических теориях.
То, что определенно код может сказать нам, - то, что буферизация - хорошая идея. Первое текстовое поле устанавливает общее количество данных для отправки. Второй задает количество выборок, на которые нужно разделить данные. Вы скоро обнаружите, что накладные расходы с выборками заметны. Флажок позволяет выбрать, передавать данные или нет. Это начинает иметь значение с большим количеством данных, как и ожидалось.
Пожалуйста, прости грязный код, я не могу заставить себя вести себя при написании захватывающих тестовых фрагментов. Я создал это время
function WorkerFN() {
console.log('WORKER: Worker ready for data.');
// Ammount of data expected
var expectedData = 0;
// Ammount of data received
var receivedData = 0;
self.onmessage = function(e) {
var type = e.data.type;
if(type=="data") {
receivedData+=e.data.data.byteLength;
self.postMessage({type: "timeResponse", timeStart: e.data.time, timeHere: performance.now(), bytes: e.data.data.byteLength, all:expectedData<=receivedData});
}
else if(type=="expectData") {
if(receivedData>0 && receivedData<expectedData) {
console.warn("There is transmission in progress already!");
}
console.log("Expecting ", e.data.bytes, " bytes of data.");
expectedData = e.data.bytes;
receivedData = 0;
}
}
}
var worker = new Worker(URL.createObjectURL(new Blob(["("+WorkerFN.toString()+")()"], {type: 'text/javascript'})));
/** SPEED CALCULATION IN THIS BLOCK **/
var results = {
transfered: 0,
timeIntegral: 0 //Total time between sending data and receiving confirmation
}
// I just love getters and setters. They are so irresistably confusing :)
// ... little bit like women. You think you're just changing a value and whoops - a function triggers
Object.defineProperty(results, "speed", {get: function() {
if(this.timeIntegral>0)
return (this.transfered/this.timeIntegral)*1000;
else
return this.transfered==0?0:Infinity;
}
});
// Worker sends times he received the messages with data, we can compare them with sent time
worker.addEventListener("message", function(e) {
var type = e.data.type;
if(type=="timeResponse") {
results.transfered+=e.data.bytes;
results.timeIntegral+=e.data.timeHere-e.data.timeStart;
// Display finish message if allowed
if(e.data.all) {
status("Done. Approx speed: "+humanFileSize(Math.round(results.speed/100)/10, true)+"/s");
addRecentResult();
}
}
});
/** GUI CRAP HERE **/
// Firefox caches disabled values after page reload, which makes testing a pain
$(".disableIfWorking").attr("disabled", false);
$("#start_measure").click(startMeasure);
$("#bytes").on("input", function() {
$("#readableBytes").text(humanFileSize(this.value, true));
});
$("#readableBytes").text(humanFileSize($("#bytes").val()*1||0, true));
function addRecentResult() {
var bytes = $("#bytes").val()*1;
var chunks = $("#chunks").val()*1;
var bpch = Math.ceil(bytes/chunks);
var string = '<tr><td class="transfer '+($("#transfer")[0].checked)+'"> </td><td class="speed">'+humanFileSize(results.speed, true)+'/s</td><td class="bytes">'+humanFileSize(bytes, true)+'</td><td class="bpch">'+humanFileSize(bpch, true)+'</td><td class="time">'+results.timeIntegral+'</td></tr>';
if($("#results td.transfer").length==0)
$("#results").append(string);
else
$(string).insertBefore($($("#results td.transfer")[0].parentNode));
}
function status(text, className) {
$("#status_value").text(text);
if(typeof className=="string")
$("#status")[0].className = className;
else
$("#status")[0].className = "";
}
window.addEventListener("error",function(e) {
status(e.message, "error");
// Enable buttons again
$(".disableIfWorking").attr("disabled", false);
});
function startMeasure() {
if(Number.isNaN(1*$("#bytes").val()) || Number.isNaN(1*$("#chunks").val()))
return status("Fill the damn fields!", "error");
$(".disableIfWorking").attr("disabled", "disabled");
DataFabricator(1*$("#bytes").val(), 1*$("#chunks").val(), sendData);
}
/** SENDING DATA HERE **/
function sendData(dataArray, bytes, bytesPerChunk, transfer, currentOffset) {
// Initialisation before async recursion
if(typeof currentOffset!="number") {
worker.postMessage({type:"expectData", bytes: bytesPerChunk*dataArray.length});
// Reset results
results.timeIntegral = 0;
results.transfered = 0;
results.finish = false;
setTimeout(sendData, 500, dataArray, bytes, bytesPerChunk, $("#transfer")[0].checked, 0);
}
else {
var param1 = {
type:"data",
time: performance.now(),
data: dataArray[currentOffset]
};
// I decided it's optimal to write code twice and use if
if(transfer)
worker.postMessage(param1, [dataArray[currentOffset]]);
else
worker.postMessage(param1);
// Allow GC
dataArray[currentOffset] = undefined;
// Increment offset
currentOffset++;
// Continue or re-enable controls
if(currentOffset<dataArray.length) {
// Update status
status("Sending data... "+Math.round((currentOffset/dataArray.length)*100)+"% at "+humanFileSize(Math.round(results.speed/100)/10, true)+"/s");
setTimeout(sendData, 100, dataArray, bytes, bytesPerChunk, transfer, currentOffset);
}
else {
//status("Done. Approx speed: "+humanFileSize(Math.round(results.speed/100)/10, true)+"/s");
$(".disableIfWorking").attr("disabled", false);
results.finish = true;
}
}
}
/** CREATING DATA HERE **/
function DataFabricator(bytes, chunks, callback) {
var loop;
var args = [
chunks, // How many chunks to create
bytes, // How many bytes to transfer total
Math.ceil(bytes/chunks), // How many bytes per chunk, byt min 1 byte per chunk
0, // Which offset of current chunk are we filling
[], // Array of existing chunks
null, // Currently created chunk
];
// Yeah this is so damn evil it randomly turns bytes in your memory to 666
// ... yes I said BYTES
(loop=function(chunks, bytes, bytesPerChunk, chunkOffset, chunkArray, currentChunk) {
var time = performance.now();
// Runs for max 40ms
while(performance.now()-time<40) {
if(currentChunk==null) {
currentChunk = new Uint8Array(bytesPerChunk);
chunkOffset = 0;
chunkArray.push(currentChunk.buffer);
}
if(chunkOffset>=currentChunk.length) {
// This means the array is full
if(chunkArray.length>=chunks)
break;
else {
currentChunk = null;
// Back to the top
continue;
}
}
currentChunk[chunkOffset] = Math.floor(Math.random()*256);
// No need to change every value in array
chunkOffset+=Math.floor(bytesPerChunk/5)||1;
}
// Calculate progress in bytes
var progress = (chunkArray.length-1)*bytesPerChunk+chunkOffset;
status("Generating data - "+(Math.round((progress/(bytesPerChunk*chunks))*1000)/10)+"%");
if(chunkArray.length<chunks || chunkOffset<currentChunk.length) {
// NOTE: MODIFYING arguments IS PERFORMANCE KILLER!
Array.prototype.unshift.call(arguments, loop, 5);
setTimeout.apply(null, arguments);
}
else {
callback(chunkArray, bytes, bytesPerChunk);
Array.splice.call(arguments, 0);
}
}).apply(this, args);
}
/** HELPER FUNCTIONS **/
// Thanks: http://stackru.com/a/14919494/607407
function humanFileSize(bytes, si) {
var thresh = si ? 1000 : 1024;
if(Math.abs(bytes) < thresh) {
return bytes + ' B';
}
var units = si
? ['kB','MB','GB','TB','PB','EB','ZB','YB']
: ['KiB','MiB','GiB','TiB','PiB','EiB','ZiB','YiB'];
var u = -1;
do {
bytes /= thresh;
++u;
} while(Math.abs(bytes) >= thresh && u < units.length - 1);
return bytes.toFixed(1)+' '+units[u];
}
* {margin:0;padding:0}
#start_measure {
border: 1px solid black;
background-color:orange;
}
button#start_measure[disabled] {
border: 1px solid #333;
font-style: italic;
background-color:#AAA;
width: 100%;
}
.buttontd {
text-align: center;
}
#status {
margin-top: 3px;
border: 1px solid black;
}
#status.error {
color: yellow;
font-weight: bold;
background-color: #FF3214;
}
#status.error div.status_text {
text-decoration: underline;
background-color: red;
}
#status_value {
display: inline-block;
border-left: 1px dotted black;
padding-left: 1em;
}
div.status_text {
display: inline-block;
background-color: #EEE;
}
#results {
width: 100%
}
#results th {
padding: 3px;
border-top:1px solid black;
}
#results td, #results th {
border-right: 1px dotted black;
}
#results td::first-child, #results th::first-child {
border-left: 1px dotted black;
}
#results td.transfer.false {
background-color: red;
}
#results td.transfer.true {
background-color: green;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<table>
<tr><td>Bytes to send total: </td><td><input class="disableIfWorking" id="bytes" type="text" pattern="\d*" placeholder="1024"/></td><td id="readableBytes"></td></tr>
<tr><td>Divide in chunks: </td><td><input class="disableIfWorking" id="chunks" type="text" pattern="\d*" placeholder="number of chunks"/></td><td></td></tr>
<tr><td>Use transfer: </td><td> <input class="disableIfWorking" id="transfer" type="checkbox" checked /></td><td></td></tr>
<tr><td colspan="2" class="buttontd"><button id="start_measure" class="disableIfWorking">Start measuring speed</button></td><td></td></tr>
</table>
<div id="status"><div class="status_text">Status </div><span id="status_value">idle</span></div>
<h2>Recent results:</h2>
<table id="results" cellpading="0" cellspacing="0">
<tr><th>transfer</th><th>Speed</th><th>Volume</th><th>Per chunk</th><th>Time (only transfer)</th></tr>
</table>
2. Буферизация
Я буду придерживаться примера указателя мыши, потому что это легко моделировать. Мы сделаем программу, которая рассчитывает расстояние до указателя мыши с помощью веб-работника.
То, что мы собираемся сделать, это настоящая старая буферизация. Мы создаем массив фиксированного размера (только те, которые позволяют передавать работникам) и заполняем его, помня последний заполненный пункт. Когда мы в конце, мы можем отправить массив и создать другой.
// Creating a buffer
this.buffer = new Uint16Array(256);
this.bufferOffset = 0;
Тогда мы можем легко сохранять координаты, пока мы не bufferOffset
переполнить buffer
:
if(this.bufferOffset>=this.buffer.length)
this.sendAndResetBuffer();
this.buffer[this.bufferOffset++] = X;
this.buffer[this.bufferOffset++] = Y;
3. Передача данных
Вы уже видели пример на MDN (верно...?), Поэтому просто краткий обзор:
worker.postMessage(myTypedArray.buffer, [myTypedArray.buffer]);
// The buffer must be empty now!
console.assert(myTypedArray.buffer.byteLength==0)
4. Буферный псевдокласс
Вот то, что я пришел для буферизации и отправки данных. Класс создается с желаемой максимальной длиной буфера. Затем он сохраняет данные (расположение указателей в данном случае) и отправляет их в Worker.
/** MousePointerBuffer saves mouse locations and when it's buffer is full,
sends them as array to the web worker.
* worker - valid worker object ready to accept messages
* buffer_size - size of the buffer, in BYTES, not numbers or points
**/
function MousePointerBuffer(worker, buffer_size) {
this.worker = worker;
if(buffer_size%4!=0)
throw new Error("MousePointerBuffer requires complement of 4 bytes number, because 1 mouse point is 2 shorts which is 4 bytes!");
this.buffer_size = buffer_size/2;
// Make buffer lazy
this.buffer = null;
this.bufferOffset = 0;
// This will print the aproximate time taken to send data + all of the overheads
worker.addEventListener("message", function(e) {
if(e.data.type=="timer")
console.log("Approximate time: ", e.data.time-this.lastSentTime);
}.bind(this));
}
MousePointerBuffer.prototype.makeBuffer = function() {
if(this.buffer!=null) {
// Buffer created and not full
if(this.bufferOffset<this.buffer_size)
return;
// Buffer full, send it then re-create
else
this.sendBuffer();
}
this.buffer = new Uint16Array(this.buffer_size);
this.bufferOffset = 0;
}
/** Sends current buffer, even if not full. Data is sent as array
[ArrayBuffer buffer, Number bufferLength] where buffer length means
occupied bytes. **/
MousePointerBuffer.prototype.sendBuffer = function() {
this.lastSentTime = performance.now();
console.log("Sending ",this.buffer.buffer.byteLength," bytes at: ",this.lastSentTime);
this.worker.postMessage([this.buffer.buffer, this.bufferOffset]
, [this.buffer.buffer] // Comment this line out to see
// How fast is it without transfer
);
// See? Bytes are gone.
console.log("Bytes in buffer after sending: ",this.buffer.buffer.byteLength);
this.buffer = null;
this.bufferOffset = 0;
}
/* Creates event callback for mouse move events. Callback is stored in
.listener property for later removal **/
MousePointerBuffer.prototype.startRecording = function() {
// The || expression alows to use cached listener from the past
this.listener = this.listener||this.recordPointerEvent.bind(this);
window.addEventListener("mousemove", this.listener);
}
/* Can be used to stop any time, doesn't send buffer though! **/
MousePointerBuffer.prototype.stopRecording = function() {
window.removeEventListener("mousemove", this.listener);
}
MousePointerBuffer.prototype.recordPointerEvent = function(event) {
// This is probably not very efficient but makes code shorter
// Of course 90% time that function call just returns immediatelly
this.makeBuffer();
// Save numbers - remember that ++ first returns then increments
this.buffer[this.bufferOffset++] = event.clientX;
this.buffer[this.bufferOffset++] = event.clientY;
}
4. Живой пример
function WorkerFN() {
console.log('WORKER: Worker ready for data.');
// Variable to store mouse pointer path distance
var dist = 0;
// Last coordinates from last iteration - filled by first iteration
var last_x = null,
last_y = null;
// Sums pythagorian distances between points
function calcPath(array, lastPoint) {
var i=0;
// If first iteration, first point is the inital one
if(last_x==null||last_y==null) {
last_x = array[0];
last_y = array[1];
// So first point is already skipped
i+=2;
}
// We're iterating by 2 so redyce final length by 1
var l=lastPoint-1
// Now loop trough points and calculate distances
for(; i<l; i+=2) {
console.log(dist,last_x, last_y);
dist+=Math.sqrt((last_x-array[i]) * (last_x-array[i])+
(last_y-array[i+1])*(last_y-array[i+1])
);
last_x = array[i];
last_y = array[i+1];
}
// Tell the browser about the distance
self.postMessage({type:"dist", dist: dist});
}
self.onmessage = function(e) {
if(e.data instanceof Array) {
self.postMessage({type:'timer', time:performance.now()});
setTimeout(calcPath, 0, new Uint16Array(e.data[0]), e.data[1]);
}
else if(e.data.type=="reset") {
self.postMessage({type:"dist", dist: dist=0});
}
}
}
var worker = new Worker(URL.createObjectURL(new Blob(["("+WorkerFN.toString()+")()"], {type: 'text/javascript'})));
/** MousePointerBuffer saves mouse locations and when it's buffer is full,
sends them as array to the web worker.
* worker - valid worker object ready to accept messages
* buffer_size - size of the buffer, in BYTES, not numbers or points
**/
function MousePointerBuffer(worker, buffer_size) {
this.worker = worker;
if(buffer_size%4!=0)
throw new Error("MousePointerBuffer requires complement of 4 bytes number, because 1 mouse point is 2 shorts which is 4 bytes!");
this.buffer_size = buffer_size/2;
// Make buffer lazy
this.buffer = null;
this.bufferOffset = 0;
// This will print the aproximate time taken to send data + all of the overheads
worker.addEventListener("message", function(e) {
if(e.data.type=="timer")
console.log("Approximate time: ", e.data.time-this.lastSentTime);
}.bind(this));
}
MousePointerBuffer.prototype.makeBuffer = function() {
if(this.buffer!=null) {
// Buffer created and not full
if(this.bufferOffset<this.buffer_size)
return;
// Buffer full, send it then re-create
else
this.sendBuffer();
}
this.buffer = new Uint16Array(this.buffer_size);
this.bufferOffset = 0;
}
/** Sends current buffer, even if not full. Data is sent as array
[ArrayBuffer buffer, Number bufferLength] where buffer length means
occupied bytes. **/
MousePointerBuffer.prototype.sendBuffer = function() {
this.lastSentTime = performance.now();
console.log("Sending ",this.buffer.buffer.byteLength," bytes at: ",this.lastSentTime);
this.worker.postMessage([this.buffer.buffer, this.bufferOffset]
, [this.buffer.buffer] // Comment this line out to see
// How fast is it without transfer
);
// See? Bytes are gone.
console.log("Bytes in buffer after sending: ",this.buffer.buffer.byteLength);
this.buffer = null;
this.bufferOffset = 0;
}
/* Creates event callback for mouse move events. Callback is stored in
.listener property for later removal **/
MousePointerBuffer.prototype.startRecording = function() {
// The || expression alows to use cached listener from the past
this.listener = this.listener||this.recordPointerEvent.bind(this);
window.addEventListener("mousemove", this.listener);
}
/* Can be used to stop any time, doesn't send buffer though! **/
MousePointerBuffer.prototype.stopRecording = function() {
window.removeEventListener("mousemove", this.listener);
}
MousePointerBuffer.prototype.recordPointerEvent = function(event) {
// This is probably not very efficient but makes code shorter
// Of course 90% time that function call just returns immediatelly
this.makeBuffer();
// Save numbers - remember that ++ first returns then increments
this.buffer[this.bufferOffset++] = event.clientX;
this.buffer[this.bufferOffset++] = event.clientY;
}
var buffer = new MousePointerBuffer(worker, 400);
buffer.startRecording();
// Cache text node reffernce here
var textNode = document.getElementById("px").childNodes[0];
worker.addEventListener("message", function(e) {
if(e.data.type=="dist") {
textNode.data=Math.round(e.data.dist);
}
});
// The reset button
document.getElementById("reset").addEventListener("click", function() {
worker.postMessage({type:"reset"});
buffer.buffer = new Uint16Array(buffer.buffer_size);
buffer.bufferOffset = 0;
});
* {margin:0;padding:0;}
#px {
font-family: "Courier new", monospace;
min-width:100px;
display: inline-block;
text-align: right;
}
#square {
width: 200px;
height: 200px;
border: 1px dashed red;
display:table-cell;
text-align: center;
vertical-align: middle;
}
Distance traveled: <span id="px">0</span> pixels<br />
<button id="reset">Reset</button>
Try this, if you hve steady hand, you will make it 800px around:
<div id="square">200x200 pixels</div>
This demo is printing into normal browser console, so take a look there.
4.1 Соответствующие строки в демо
В строке 110 класс инициализируется, поэтому вы можете изменить длину буфера:
var buffer = new MousePointerBuffer(worker, 400);
В строке 83 вы можете закомментировать команду переноса для имитации обычной операции копирования. Мне кажется, что разница действительно незначительна в этом случае:
, [this.buffer.buffer] // Comment this line out to see
// How fast is it without transfer
Они так же быстры, как и ядро процессора, на котором оно работает. Тем не менее, связь между процессами всегда влечет за собой некоторые накладные расходы, поэтому пакетная обработка, вероятно, принесет вам дополнительную производительность. Лично я, вероятно, использовал бы таймер для отправки местоположения мыши или истории местоположения каждые 25 мс.
Вопрос, который вы должны задать себе: как часто вам нужны обновления? Достаточно ли 1 обновления в секунду? 100? 1000? В какой момент вы просто сжигаете циклы процессора без дополнительной ценности.