(1a) Означает ли это, что первый readFile () начнет читать файл только после того, как обратный вызов второго readFile () выполнит свою работу?
Нет. Каждый readFile()
фактически состоит из нескольких шагов (открытие файла, чтение блока, чтение блока ... закрытие файла). Логический поток между шагами контролируется кодом Javascript в библиотеке node.js fs
. Но часть каждого шага реализуется с помощью собственного многопоточного кода в libuv с использованием пула потоков.
Итак, будет инициирован первый шаг первого readFile()
, а затем управление будет возвращено интерпретатору JS. Затем будет инициирован первый шаг второго readFile()
, а затем управление будет возвращено интерпретатору JS. Он может переключаться между двумя readFile()
операциями, пока интерпретатор JS не занят. Но если интерпретатор JS на какое-то время будет занят, он остановит дальнейший прогресс, когда текущий шаг, выполняемый в фоновом режиме, завершится. В конце ответа есть полная пошаговая хронология, если вы хотите проследить детали каждого шага.
(1b) Насколько я понимаю, в nodeJs цикл событий передает readFile () многопоточному Libuv. Однако мне интересно, в каком порядке они передаются. Если эти две функции readFile () не работают параллельно, почему вторая функция readFile () всегда выполняется первой?
fs.readFile()
сам по себе не реализован в libuv. Он реализован в виде серии отдельных шагов в JavaScript в node.js. Каждый отдельный шаг (открытие файла, чтение блока, закрытие файла) реализован в libuv, но Javascript в библиотеке fs
управляет последовательностью между шагами. Итак, думайте о fs.readfile()
как о серии вызовов libuv. Когда у вас одновременно выполняются две fs.readFile()
операции, каждая из них будет иметь некоторую операцию libuv, выполняющуюся в любой момент времени, и один шаг для каждой fs.readFile()
может выполняться параллельно из-за реализации пула потоков в libuv. Но между каждым шагом в процессе управление возвращается интерпретатору JS. Таким образом, если интерпретатор будет занят в течение некоторого длительного периода времени, дальнейший прогресс в планировании следующего шага другой fs.readFile()
операции останавливается.
(2) По умолчанию Libuv имеет четыре потока для Node.js. Итак, выполняются ли эти два readFile () в одном потоке? Я не уверен, есть ли среди этих четырех потоков только один для readFile ().
Я думаю, что это описано в двух предыдущих объяснениях. readFile()
сам по себе не реализован в собственном коде libuv. Вместо этого он написан на Javascript с вызовами операций открытия, чтения и закрытия, которые написаны в собственном коде и используют libuv и пул потоков.
Вот полный отчет о том, что происходит. Чтобы полностью понять, нужно знать об этом:
Основные понятия
- Однопоточный, без упреждающего характера node.js, на котором запущен ваш Javascript (при условии, что здесь вручную не запрограммированы
WorkerThreads
, а это не так).
- Многопоточный собственный код файлового ввода-вывода модуля
fs
и принцип его работы.
- Как асинхронные операции собственного кода сообщают о завершении через очередь событий и как работает планирование цикла событий, когда интерпретатор JS чем-то занят.
Асинхронный, неблокирующий
Я полагаю, вы знаете, что fs.readFile()
асинхронный и неблокирующий. Это означает, что когда вы его вызываете, все, что он делает, - это инициирует операцию чтения файла, а затем он переходит прямо к следующей строке кода на верхнем уровне после fs.readFile()
(а не кода внутри обратного вызова, который вы ему передаете).
Итак, сокращенная версия вашего кода в основном такова:
fs.readFile(x, funcA);
fs.readFile(y, funcB);
Если мы добавим к этому логирование:
function funcA() {
console.log("funcA");
}
function funcB() {
console.log("funcB");
}
function spin(howLong) {
let finishTime = Date.now() + howLong;
// spin until howLong ms passes
while (Date.now() < finishTime) {}
}
console.log("1");
fs.readFile(x, funcA);
console.log("2");
fs.readFile(y, funcB);
console.log("3");
spin(30000); // spin for 30 seconds
console.log("4");
Вы бы увидели либо такой порядок:
1
2
3
4
A
B
или в таком порядке:
1
2
3
4
B
A
Какой из двух это будет, будет зависеть от неопределенной гонки между двумя fs.readFile()
операциями. Либо могло случиться. Также обратите внимание, что 1
, 2
, 3
и 4
регистрируются до того, как могут произойти какие-либо события асинхронного завершения. Это связано с тем, что основной поток однопоточного интерпретатора JS без упреждения занят выполнением Javascript. Он не будет извлекать следующее событие из очереди событий, пока не выполнит этот фрагмент Javascript.
Пул потоков Libuv
Как вы, кажется, уже знаете, модуль fs
использует пул потоков libuv для выполнения файлового ввода-вывода. Это не зависит от основного потока JS, поэтому эти операции чтения могут выполняться независимо от дальнейшего выполнения JS. Используя собственный код, файловый ввод-вывод будет связываться с очередью событий, когда они будут выполнены, чтобы запланировать обратный вызов завершения.
Неопределенная гонка между двумя асинхронными операциями
Итак, вы только что создали неопределенную гонку между двумя fs.readFile()
операциями, каждая из которых, вероятно, выполняется в своем собственном потоке. Небольшой файл с большей вероятностью будет завершен раньше, чем более крупный файл, потому что в более крупном файле гораздо больше данных для чтения с диска.
Какой бы fs.readFile()
ни завершился раньше, первым вставит свой обратный вызов в очередь событий. Когда интерпретатор JS свободен, он выберет следующее событие из очереди событий. То, что завершится первым, первым запустит свой обратный вызов. Поскольку небольшой файл, скорее всего, завершится первым (о чем вы сообщаете), он должен выполнить свой обратный вызов. Теперь, когда он выполняет свой обратный вызов, это просто Javascript, и даже если большой файл может завершиться и вставить свой обратный вызов в очередь событий, этот обратный вызов не может выполняться, пока не завершится обратный вызов из небольшого файла. Итак, он завершается, и ТОГДА запускается обратный вызов из большого файла.
В общем, вы никогда не должны писать подобный код, если вам совсем не важно, в каком порядке завершаются две асинхронные операции, потому что это неопределенная гонка, и вы не можете рассчитывать, какая из них завершится первой. Из-за асинхронного неблокирующего характера fs.readFile()
нет гарантии, что первая инициированная файловая операция завершится первой. Это не что иное, как запуск двух отдельных HTTP-запросов один за другим. Вы не знаете, какой из них завершится первым.
Пошаговая хронология
Вот пошаговая хронология того, что происходит:
- Звоните
fs.readFile("./large.txt", ...)
;
- В коде Javascript это инициирует открытие файла
large.txt
путем вызова собственного кода, а затем возвращается. Открытие фактического файла обрабатывается libuv в собственном коде, и когда это будет сделано, событие будет вставлено в очередь событий JS.
- Сразу после того, как эта операция инициируется, возвращается первый
fs.readFile()
(еще не сделано, все еще обрабатывается внутри).
- Теперь интерпретатор JS выбирает следующую строку кода и запускает
fs.readFile("./small.txt", ...);
- В коде Javascript это инициирует открытие файла
small.txt
, вызывая собственный код, а затем возвращается. Открытие фактического файла обрабатывается libuv в собственном коде, и когда это будет сделано, событие будет вставлено в очередь событий JS.
- Сразу после того, как эта операция инициирована, возвращается вторая
fs.readFile()
(еще не завершена, все еще выполняется внутренняя обработка).
- Интерпретатор JS фактически может запускать любой следующий код или обрабатывать любые входящие события.
- Затем, спустя некоторое время, одна из двух
fs.readFile()
операций завершает свой первый шаг (открытие файла), событие вставляется в очередь событий JS, и когда у интерпретатора JS есть время, вызывается обратный вызов. Поскольку открытие каждого файла занимает примерно одинаковое время операции, вполне вероятно, что операция открытия файла large.txt
завершится первой, но это не гарантируется.
- После успешного открытия файла он инициирует асинхронную операцию для чтения первого фрагмента из файла. Это снова асинхронно и обрабатывается libuv, поэтому, как только это инициируется, оно возвращает управление интерпретатору JS.
- Открытие второго файла, вероятно, завершается следующим и делает то же, что и первое, инициирует чтение первого фрагмента данных с диска и возвращает управление интерпретатору JS.
- Затем один из этих двух блоков чтения завершается и вставляет событие в очередь событий, и когда интерпретатор JS свободен, для его обработки вызывается обратный вызов. На этом этапе это может быть либо большой, либо маленький файл, но для простоты объяснения давайте предположим, что первая часть большого файла завершается первой. Он буферизует этот кусок, увидит, что есть больше данных для чтения, и инициирует другую операцию асинхронного чтения, а затем вернет управление обратно интерпретатору JS.
- Затем заканчивается чтение другого первого фрагмента. Он буферизует этот фрагмент и увидит, что данных для чтения больше нет. На этом этапе он выполнит операцию закрытия файла, которая снова будет обработана libuv, и управление будет возвращено интерпретатору JS.
- Одна из двух предыдущих операций завершается (чтение второго блока из
large.txt
или закрытие файла small.txt
) и вызывается ее обратный вызов. Поскольку операция закрытия не обязательно должна касаться диска (она просто переходит в ОС), для объяснения предположим, что операция закрытия завершается первой. Это закрытие запускает конец fs.ReadFile()
для small.txt
и вызывает обратный вызов завершения для этого.
- Итак, на этом этапе
small.txt
завершено, и large.txt
прочитал один фрагмент из своего файла и ожидает завершения второго фрагмента для чтения.
- Теперь ваш код выполняет цикл
for
, который занимает все необходимое время.
- К тому моменту, когда интерпретатор JS снова становится свободным, второй файл, считанный из
large.txt
, вероятно, завершен, поэтому интерпретатор JS находит это событие в очереди событий и выполняет обратный вызов, чтобы выполнить дополнительную обработку при чтении дополнительных фрагментов из этого файла.
- Процесс чтения фрагмента, возврата управления обратно интерпретатору, ожидания следующего события завершения фрагмента и последующей буферизации этого фрагмента продолжается до тех пор, пока все данные не будут прочитаны.
- Затем для
large.txt
инициируется операция закрытия.
- Когда эта операция закрытия завершена, вызывается обратный вызов для
fs.readFile()
для large.txt
, и ваш код, который синхронизирует large.txt
, будет измерять завершение.
Итак, поскольку логика fs.readFile()
реализована в Javascript с рядом дискретных асинхронных шагов, каждый из которых в конечном итоге обрабатывается libuv (открыть файл, прочитать фрагмент - N раз, закрыть файл), будет чередование работы между двумя файлы. Чтение меньшего файла завершится первым только потому, что в нем меньше и меньше операций чтения. Когда он завершится, у большого файла останется еще несколько фрагментов для чтения, и останется операция закрытия. Поскольку несколько шагов fs.readFile()
управляются с помощью Javascript, когда вы выполняете длинный for
цикл в small.txt
завершении, вы также останавливаете операцию fs.readFile()
для файла large.txt
. Независимо от того, какой фрагмент читался, когда произошел этот цикл, он завершится в фоновом режиме, но следующее чтение фрагмента не будет выполнено до тех пор, пока не завершится обратный вызов небольшого файла.
Похоже, что у node.js была бы возможность улучшить отзывчивость fs.readFile()
в таких конкурентных условиях, если бы эта операция была полностью переписана в машинном коде, чтобы одна операция в машинном коде могла читать содержимое всего файла, а не все эти переключается между однопоточным основным потоком JS и libuv. Если бы это было так, большой цикл for
не останавливал бы выполнение large.txt
, потому что он выполнялся бы полностью в потоке libuv, а не ждал бы нескольких циклов от интерпретатора JS, чтобы перейти к следующему этапу.
Мы можем предположить, что если бы оба файла могли быть прочитаны одним блоком, то длинный цикл for
не остановил бы многого. Оба файла будут открыты (что должно занять примерно одинаковое время для каждого). Обе операции инициируют чтение своего первого фрагмента. Чтение меньшего файла, скорее всего, завершится первым (меньше данных для чтения), но на самом деле это зависит как от ОС, так и от логики контроллера диска. Поскольку фактические чтения передаются многопоточному коду, оба чтения будут отложены одновременно. Предполагая, что сначала завершается меньшее чтение, он запускает завершение, а затем во время цикла занятости большое чтение завершается, вставляя событие в очередь событий. Когда цикл занятости завершается, единственное, что остается сделать с большим файлом (но все же что-то, что может быть прочитано в одном фрагменте), - это закрыть файл, что является более быстрой операцией.
Но когда более крупный файл не может быть прочитан одним фрагментом и ему требуется несколько фрагментов чтения, поэтому его прогресс действительно останавливается из-за цикла занятости, потому что фрагмент завершается, но следующий фрагмент не назначается до тех пор, пока цикл занятости сделано.
Тестирование
Итак, давайте проверим всю эту теорию. Я создал два файла. small.txt
составляет 558 байт. large.txt
составляет 255 194 500 байт.
Затем я написал следующую программу, чтобы рассчитать их время и позволить нам при желании сделать 3-секундный цикл вращения после завершения малого.
const fs = require('fs');
let doSpin = false; // -s will set this to true
let fname = "./large.txt";
for (let i = 2; i < process.argv.length; i++) {
let arg = process.argv[i];
console.log(`"${arg}"`);
if (arg.startsWith("-")) {
switch(arg) {
case "-s":
doSpin = true;
break;
default:
console.log(`Unknown arg ${arg}`);
process.exit(1);
break;
}
} else {
fname = arg;
}
}
function padDecimal(num, n = 3) {
let str = num.toFixed(n);
let index = str.indexOf(".");
if (index === -1) {
str += ".";
index = str.length - 1;
}
let zeroesToAdd = n - (str.length - index);
while (zeroesToAdd-- >= 0) {
str += "0";
}
return str;
}
let startTime;
function log(msg) {
if (!startTime) {
startTime = Date.now();
}
let diff = (Date.now() - startTime) / 1000; // in seconds
console.log(padDecimal(diff), ":", msg)
}
function funcA(err, data) {
if (err) {
log("error on large");
log(err);
return;
}
log("large completed");
}
function funcB(err, data) {
if (err) {
log("error on small");
log(err);
return;
}
log("small completed");
if (doSpin) {
spin(3000);
log("spin completed");
}
}
function spin(howLong) {
let finishTime = Date.now() + howLong;
// spin until howLong ms passes
while (Date.now() < finishTime) {}
}
log("start");
fs.readFile(fname, funcA);
log("large initiated");
fs.readFile("./small.txt", funcB);
log("small initiated");
Затем (используя узел v12.13.0) я запустил его как с 3-секундным вращением, так и без него. Без вращения я получаю такой вывод:
0.000 : start
0.015 : large initiated
0.016 : small initiated
0.021 : small completed
0.240 : large completed
Это показывает разницу в 0,219 секунды между временем выполнения малого и большого (при одновременном выполнении обоих).
Затем, вставив 3-секундную задержку, мы получаем следующий результат:
0.000 : start
0.003 : large initiated
0.004 : small initiated
0.009 : small completed
3.010 : spin completed
3.229 : large completed
У нас есть точно такая же разница в 0,219 секунды между временем выполнения малого и большого (при одновременном выполнении обоих). Это показывает, что большой fs.readFile()
практически не продвинулся во время 3-х секундного вращения. Его прогресс был полностью заблокирован. Как мы предположили в предыдущем объяснении, это, по-видимому, связано с тем, что переход от одного фрагментированного чтения к следующему записывается в Javascript, и пока выполняется цикл вращения, этот переход к следующему фрагменту блокируется, поэтому он не может ничего сделать. дальнейший прогресс.
Насколько большой файл занимает секунду после обработки большого файла?
Если вы посмотрите код для fs.readFile()
в источнике для узла v12.13.0, вы увидите, что размер блока, который он читает, равен 512 * 1024, то есть 512 КБ. Итак, теоретически возможно, что файл большего размера может закончиться первым, если его можно будет прочитать одним фрагментом. Произойдет ли это на самом деле или нет, зависит от некоторых деталей реализации ОС и диска, но я подумал, что попробую это на своем ноутбуке с текущей версией Windows 10 с SSD-накопителем.
Я обнаружил, что для «большого» файла размером 255 КБ он заканчивается раньше, чем маленький файл (по существу, в порядке выполнения). Таким образом, поскольку чтение большого файла начинается до чтения небольшого файла, даже если у него есть больше данных для чтения, оно все равно завершится раньше, чем маленький файл.
0.000 : start
0.003 : large initiated
0.003 : small initiated
0.007 : large completed
0.008 : small completed
Имейте в виду, что это зависит от ОС и диска, поэтому это не гарантируется.
person
jfriend00
schedule
04.01.2020