Skip to content

Commit 43a9d71

Browse files
committed
http: reduce per-request server overhead
Eliminate per-request work that is invariant or redundant on the server response path: - end(chunk) issued a second, empty socket.write whose only purpose was to carry the finish callback, paying a full Writable pass and an extra cork-queue entry per response. When the header block is already rendered and the framing is final (no chunked trailer, no strict content-length accounting), the finish callback rides the body write itself; writes complete in order, so the observable finish timing is unchanged. - The response options bag and the pending-data callback only depend on the connection, not the request: allocate them once per socket and reuse them for every response on a keep-alive connection. - The response close listener is a no-op unless a response is attached (socket._httpMessage guard), so it is installed once per socket instead of paying the add/removeListener pair per response. - Status lines for default reason phrases are cached per status code, and the Keep-Alive header line is memoized per (timeout, max) configuration. Measured with a CPU-per-request harness (fresh process per sample, 25x600k-request interleaved samples, Welch t-test) on a keep-alive hello-world server: +7.34% requests per CPU-second (p=2.0e-14), and about +6.4% requests/sec under autocannon. All test/parallel http and https tests pass. Assisted-by: Grok Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
1 parent 85c887a commit 43a9d71

2 files changed

Lines changed: 91 additions & 24 deletions

File tree

lib/_http_outgoing.js

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -537,12 +537,19 @@ function _storeHeader(firstLine, headers) {
537537
const keepAliveTimeout = this._keepAliveTimeout;
538538
if (keepAliveTimeout && this._defaultKeepAlive) {
539539
const timeoutSeconds = MathFloor(keepAliveTimeout / 1000);
540-
const maxRequestsPerSocket = this._maxRequestsPerSocket;
541-
let max = '';
542-
if (~~maxRequestsPerSocket > 0) {
543-
max = `, max=${maxRequestsPerSocket}`;
540+
const maxRequestsPerSocket = ~~this._maxRequestsPerSocket;
541+
// The Keep-Alive line is invariant per server configuration, so
542+
// memoize the last rendered (timeout, max) pair instead of
543+
// re-building the string for every response.
544+
if (timeoutSeconds !== keepAliveLine.timeout ||
545+
maxRequestsPerSocket !== keepAliveLine.max) {
546+
keepAliveLine.timeout = timeoutSeconds;
547+
keepAliveLine.max = maxRequestsPerSocket;
548+
keepAliveLine.value = maxRequestsPerSocket > 0 ?
549+
`Keep-Alive: timeout=${timeoutSeconds}, max=${maxRequestsPerSocket}\r\n` :
550+
`Keep-Alive: timeout=${timeoutSeconds}\r\n`;
544551
}
545-
header += `Keep-Alive: timeout=${timeoutSeconds}${max}\r\n`;
552+
header += keepAliveLine.value;
546553
}
547554
} else {
548555
this._last = true;
@@ -930,6 +937,10 @@ ObjectDefineProperty(OutgoingMessage.prototype, 'writableNeedDrain', {
930937
});
931938

932939
const crlf_buf = Buffer.from('\r\n');
940+
941+
// Memoized `Keep-Alive: timeout=N[, max=M]\r\n` line; servers use one
942+
// configuration, so this is effectively computed once per process.
943+
const keepAliveLine = { timeout: -1, max: -1, value: '' };
933944
OutgoingMessage.prototype.write = function write(chunk, encoding, callback) {
934945
if (typeof encoding === 'function') {
935946
callback = encoding;
@@ -1140,6 +1151,17 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) {
11401151
encoding = null;
11411152
}
11421153

1154+
// When the final body chunk is written with a known length (no chunked
1155+
// trailer to send), the finish notification can ride the body write's
1156+
// completion instead of issuing a second, empty socket.write whose only
1157+
// purpose is to carry the callback: writes complete in order, so the
1158+
// observable finish timing is unchanged while a full Writable pass per
1159+
// response disappears. Not applicable under strict content-length
1160+
// accounting, where the mismatch check must run after the write and
1161+
// before finish is scheduled.
1162+
let finish;
1163+
let finishAttached = false;
1164+
11431165
if (chunk) {
11441166
if (this.finished) {
11451167
onError(this,
@@ -1152,7 +1174,20 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) {
11521174
this[kSocket].cork();
11531175
}
11541176

1155-
write_(this, chunk, encoding, null, true);
1177+
if (this._header !== null && this._hasBody && !this.chunkedEncoding &&
1178+
!this.destroyed && !strictContentLength(this)) {
1179+
// The header block is already rendered, so the framing decision is
1180+
// final: write_ deterministically reaches the single non-chunked
1181+
// _send and the callback is guaranteed to ride the body write.
1182+
// (Without a rendered header, write_'s _implicitHeader call may
1183+
// still switch the message to chunked encoding, which needs the
1184+
// trailing 0\r\n\r\n send below.)
1185+
finish = onFinish.bind(undefined, this);
1186+
write_(this, chunk, encoding, finish, true);
1187+
finishAttached = true;
1188+
} else {
1189+
write_(this, chunk, encoding, null, true);
1190+
}
11561191
} else if (this.finished) {
11571192
if (typeof callback === 'function') {
11581193
if (!this.writableFinished) {
@@ -1178,14 +1213,16 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) {
11781213
throw new ERR_HTTP_CONTENT_LENGTH_MISMATCH(this[kBytesWritten], this._contentLength);
11791214
}
11801215

1181-
const finish = onFinish.bind(undefined, this);
1216+
if (!finishAttached) {
1217+
finish ??= onFinish.bind(undefined, this);
11821218

1183-
if (this._hasBody && this.chunkedEncoding) {
1184-
this._send('0\r\n' + this._trailer + '\r\n', 'latin1', finish);
1185-
} else if (!this._headerSent || this.writableLength || chunk) {
1186-
this._send('', 'latin1', finish);
1187-
} else {
1188-
process.nextTick(finish);
1219+
if (this._hasBody && this.chunkedEncoding) {
1220+
this._send('0\r\n' + this._trailer + '\r\n', 'latin1', finish);
1221+
} else if (!this._headerSent || this.writableLength || chunk) {
1222+
this._send('', 'latin1', finish);
1223+
} else {
1224+
process.nextTick(finish);
1225+
}
11891226
}
11901227

11911228
if (this[kSocket]) {

lib/_http_server.js

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ const onResponseFinishChannel = dc.channel('http.server.response.finish');
110110

111111
const kServerResponse = Symbol('ServerResponse');
112112
const kServerResponseStatistics = Symbol('ServerResponseStatistics');
113+
const kHasResponseCloseListener = Symbol('kHasResponseCloseListener');
113114
const kUpgradeStream = Symbol('UpgradeStream');
114115

115116
const kOptimizeEmptyRequests = Symbol('OptimizeEmptyRequestsOption');
@@ -186,6 +187,10 @@ const STATUS_CODES = {
186187
511: 'Network Authentication Required', // RFC 6585 6
187188
};
188189

190+
// Lazily filled per-status-code cache of `HTTP/1.1 <code> <default reason>\r\n`
191+
// status lines (bounded by the status codes actually used).
192+
const statusLineCache = { __proto__: null };
193+
189194
const kOnExecute = HTTPParser.kOnExecute | 0;
190195
const kOnTimeout = HTTPParser.kOnTimeout | 0;
191196

@@ -298,15 +303,21 @@ ServerResponse.prototype.assignSocket = function assignSocket(socket) {
298303
throw new ERR_HTTP_SOCKET_ASSIGNED();
299304
}
300305
socket._httpMessage = this;
301-
socket.on('close', onServerResponseClose);
306+
// The handler is a no-op unless a response is attached (see the
307+
// _httpMessage guard above), so it is installed once per socket and
308+
// left in place instead of paying the add/removeListener pair for
309+
// every response on a keep-alive connection.
310+
if (socket[kHasResponseCloseListener] !== true) {
311+
socket[kHasResponseCloseListener] = true;
312+
socket.on('close', onServerResponseClose);
313+
}
302314
this.socket = socket;
303315
this.emit('socket', socket);
304316
this._flush();
305317
};
306318

307319
ServerResponse.prototype.detachSocket = function detachSocket(socket) {
308320
assert(socket._httpMessage === this);
309-
socket.removeListener('close', onServerResponseClose);
310321
socket._httpMessage = null;
311322
this.socket = null;
312323
};
@@ -467,10 +478,20 @@ function writeHead(statusCode, reason, obj) {
467478
headers = obj;
468479
}
469480

470-
if (checkInvalidHeaderChar(this.statusMessage))
481+
const statusMessage = this.statusMessage;
482+
if (checkInvalidHeaderChar(statusMessage))
471483
throw new ERR_INVALID_CHAR('statusMessage');
472484

473-
const statusLine = `HTTP/1.1 ${statusCode} ${this.statusMessage}\r\n`;
485+
// The status line for a default reason phrase is invariant per status
486+
// code: build it once per code instead of allocating the template
487+
// result for every response.
488+
let statusLine;
489+
if (statusMessage === STATUS_CODES[statusCode]) {
490+
statusLine = statusLineCache[statusCode] ??=
491+
`HTTP/1.1 ${statusCode} ${statusMessage}\r\n`;
492+
} else {
493+
statusLine = `HTTP/1.1 ${statusCode} ${statusMessage}\r\n`;
494+
}
474495

475496
if (statusCode === 204 || statusCode === 304 ||
476497
(statusCode >= 100 && statusCode <= 199)) {
@@ -810,6 +831,10 @@ function connectionListenerInternal(server, socket) {
810831
outgoingData: 0,
811832
requestsCount: 0,
812833
keepAliveTimeoutSet: false,
834+
// Per-connection caches for per-response invariants (see
835+
// parserOnIncoming): initialized here for shape stability.
836+
responseOptions: null,
837+
updateOutgoingData: null,
813838
};
814839
state.onData = socketOnData.bind(undefined,
815840
server, socket, parser, state);
@@ -1280,15 +1305,20 @@ function parserOnIncoming(server, socket, state, req, keepAlive) {
12801305
}
12811306
}
12821307

1283-
const res = new server[kServerResponse](req,
1284-
{
1285-
highWaterMark: socket.writableHighWaterMark,
1286-
rejectNonStandardBodyWrites: server.rejectNonStandardBodyWrites,
1287-
});
1308+
// The response options and the pending-data callback only depend on
1309+
// the connection, not the request: allocate them once per socket and
1310+
// reuse for every response on a keep-alive connection. Neither object
1311+
// is retained by the response (the options are only read in the
1312+
// OutgoingMessage constructor).
1313+
state.responseOptions ??= {
1314+
highWaterMark: socket.writableHighWaterMark,
1315+
rejectNonStandardBodyWrites: server.rejectNonStandardBodyWrites,
1316+
};
1317+
const res = new server[kServerResponse](req, state.responseOptions);
12881318
res._keepAliveTimeout = server.keepAliveTimeout;
12891319
res._maxRequestsPerSocket = server.maxRequestsPerSocket;
1290-
res._onPendingData = updateOutgoingData.bind(undefined,
1291-
socket, state);
1320+
res._onPendingData = state.updateOutgoingData ??=
1321+
updateOutgoingData.bind(undefined, socket, state);
12921322

12931323
res.shouldKeepAlive = keepAlive;
12941324
res[kUniqueHeaders] = server[kUniqueHeaders];

0 commit comments

Comments
 (0)