Skip to content

stream_socket_client()/fsockopen() connect on Windows is ~10–25 ms slow because getsockopt(SO_ERROR) stalls in mswsock #24171

Description

@vibbow

Description

On Windows, a synchronous TCP connect with stream_socket_client() or fsockopen() usually takes 10–25 ms. A socket_connect() to the same target takes about 0.2 ms, and so does an async stream_socket_client() followed by stream_select(). Anything built on the stream API is affected too, for example phpredis connect().

I originally reported this as bug #78825 in 2019 (Verified, still reproducible with 8.1 at the time). It still reproduces on 8.5.11. I have now tracked down the root cause, which is a race inside Windows' mswsock.dll, and have a small fix for PHP that avoids it. I'll open a PR for it.

The following code:

<?php
// Usage: php repro.php <host> <port>   (any listening TCP port; loopback reproduces too)
[, $host, $port] = $argv + [1 => '127.0.0.1', 2 => '6379'];

function bench(string $name, callable $connect): void {
    $t = [];
    for ($i = -10; $i < 300; $i++) {
        $t0 = hrtime(true);
        $h = $connect();
        $d = (hrtime(true) - $t0) / 1e6;
        is_resource($h) ? fclose($h) : socket_close($h);
        if ($i >= 0) $t[] = $d;
    }
    sort($t);
    printf("%-22s median=%7.3f p95=%7.3f max=%7.3f ms\n", $name, $t[150], $t[285], $t[299]);
}

bench('socket_connect', function () use ($host, $port) {
    $s = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
    socket_connect($s, $host, (int)$port);
    return $s;
});
bench('stream_socket_client', fn() => stream_socket_client("tcp://$host:$port"));

Resulted in this output (target is a Redis server on the LAN):

socket_connect         median=  0.225 p95=  0.339 max=  0.507 ms
stream_socket_client   median= 13.794 p95= 15.455 max= 20.593 ms

But I expected this output instead (this is the output with the fix below applied):

socket_connect         median=  0.247 p95=  0.330 max=  0.638 ms
stream_socket_client   median=  0.310 p95=  1.147 max=  2.624 ms

It also reproduces on loopback (127.0.0.1), so the network is not involved. The individual timings are bimodal: each one is either about 0.2 ms or 10–25 ms. php -n gives the same result, so ini settings and extensions are not involved.

Where the time goes in PHP

I added QueryPerformanceCounter timers around each step of php_network_connect_socket() in main/network.c and built a custom NTS x64 binary.

  • connect() takes about 0.1 ms and returns WSAEWOULDBLOCK.
  • php_pollfd_for() returns POLLOUT within about 2 µs.
  • getsockopt(sockfd, SOL_SOCKET, SO_ERROR, ...) then blocks for 10–24 ms before returning 0.

All of the extra time is spent in that getsockopt() call.

To rule PHP out, I reproduced it in plain C. The select + getsockopt(SO_ERROR) row is the same sequence PHP runs:

select + getsockopt(SO_ERROR)        median= 14.622 p95= 15.531 max= 22.478 ms  >5ms: 210/300
select only                          median=  0.199 p95=  0.340 max=  0.972 ms  >5ms: 0/300
select + 50us wait + SO_ERROR        median=  0.244 p95=  0.329 max=  0.499 ms  >5ms: 0/300
select + getsockopt(SO_TYPE)         median=  0.199 p95=  0.294 max=  0.573 ms  >5ms: 0/300
Standalone C reproducer (so_error_repro.c)
/* Winsock: getsockopt(SO_ERROR) right after a non-blocking connect completes
 * can block for 10-25 ms. Build: cl /O2 so_error_repro.c ws2_32.lib
 * Usage: so_error_repro <ip> <port>   (any listening TCP port, loopback works) */
#include <winsock2.h>
#include <ws2tcpip.h>
#include <stdio.h>
#include <stdlib.h>

static double ms(void) {
	static LARGE_INTEGER f; LARGE_INTEGER t;
	if (!f.QuadPart) QueryPerformanceFrequency(&f);
	QueryPerformanceCounter(&t);
	return t.QuadPart * 1000.0 / f.QuadPart;
}
static int cmp(const void *a, const void *b) {
	double x = *(const double *)a, y = *(const double *)b; return (x > y) - (x < y);
}

/* mode 0: connect -> select(w,e) -> getsockopt(SO_ERROR)   (what PHP does)
 * mode 1: connect -> select(w,e)                           (no getsockopt)
 * mode 2: connect -> select(w,e) -> 50us busy wait -> getsockopt(SO_ERROR)
 * mode 3: connect -> select(w,e) -> getsockopt(SO_TYPE) */
static void run(struct sockaddr_in *sa, int mode, const char *name) {
	enum { N = 300 }; double t[N]; int slow = 0;
	for (int i = -10; i < N; i++) {
		SOCKET s = socket(AF_INET, SOCK_STREAM, 0);
		u_long nb = 1; ioctlsocket(s, FIONBIO, &nb);
		double t0 = ms();
		connect(s, (struct sockaddr *)sa, sizeof(*sa));
		fd_set w, e; struct timeval tv = {5, 0};
		FD_ZERO(&w); FD_ZERO(&e); FD_SET(s, &w); FD_SET(s, &e);
		select(0, NULL, &w, &e, &tv);
		if (mode == 2) { double end = ms() + 0.05; while (ms() < end) {} }
		if (mode != 1) {
			int v = 0, len = sizeof(v);
			getsockopt(s, SOL_SOCKET, mode == 3 ? SO_TYPE : SO_ERROR, (char *)&v, &len);
		}
		double d = ms() - t0;
		closesocket(s);
		if (i >= 0) { t[i] = d; slow += d > 5; }
	}
	qsort(t, N, sizeof(double), cmp);
	printf("%-36s median=%7.3f p95=%7.3f max=%7.3f ms  >5ms: %d/%d\n",
		name, t[N / 2], t[N * 95 / 100], t[N - 1], slow, N);
}

int main(int argc, char **argv) {
	WSADATA wd; WSAStartup(MAKEWORD(2, 2), &wd);
	struct sockaddr_in sa = {0};
	sa.sin_family = AF_INET;
	sa.sin_port = htons((u_short)atoi(argc > 2 ? argv[2] : "6379"));
	inet_pton(AF_INET, argc > 1 ? argv[1] : "127.0.0.1", &sa.sin_addr);
	run(&sa, 0, "select + getsockopt(SO_ERROR)");
	run(&sa, 1, "select only");
	run(&sa, 2, "select + 50us wait + SO_ERROR");
	run(&sa, 3, "select + getsockopt(SO_TYPE)");
	return 0;
}

Why Winsock stalls

I captured the call stack of the thread while it was stuck in getsockopt(). No third-party DLLs (antivirus, VPN, LSP) were loaded in the process. Frames were resolved with Microsoft's public symbols:

ntdll!NtRemoveIoCompletion
mswsock!SockIsSocketConnected+0xc3
mswsock!WSPGetSockOpt+0x23c
ws2_32!getsockopt+0x1ae

The thread is blocked, not spinning: a slow call uses about 45k CPU cycles over about 14 ms of wall time.

Disassembling SockIsSocketConnected in mswsock.dll on Windows 11 10.0.26300 shows the following:

  1. mswsock runs a non-blocking connect as an async request. Its completion is posted to an internal completion port, SockAsyncQueuePort, which is serviced by mswsock's own background thread, SockAsyncThread. That thread appears in the process after the first non-blocking connect.
  2. For SO_ERROR, WSPGetSockOpt calls SockIsSocketConnected. If the connect has already completed in the kernel (the I/O status is no longer STATUS_PENDING) but its completion hasn't been processed in user mode yet, this function tries to process the completion itself. It calls NtRemoveIoCompletion on SockAsyncQueuePort, first with a zero timeout. On STATUS_TIMEOUT it switches to a 10 ms timeout (0xFFFFFFFFFFFE7960, i.e. -100000 × 100 ns) and loops until the socket's pending-connect context is cleared.
  3. If SockAsyncThread has already dequeued the packet and is still processing it, the queue is empty. The zero-timeout poll fails, and the calling thread then waits on an empty port for the full 10 ms, rounded up to the 15.6 ms timer tick. The background thread itself finishes within microseconds.

This accounts for everything above:

  • Bimodal timings: the result depends on which thread wins the race.
  • The size of the delay: a typical slow call takes 10–15.6 ms, and up to about 25 ms when the loop waits twice. With timeBeginPeriod(1) the slow calls drop to about 10–11 ms (max 11.7 ms), which matches a 10 ms timeout at 1 ms timer resolution.
  • A 50 µs wait avoids it: by then the background thread is done and the context is cleared, so SockIsSocketConnected returns without waiting.
  • SO_TYPE and select() are unaffected: SO_TYPE doesn't need the connect result, and select() asks AFD directly.
  • ext/sockets and async stream connects are fast: neither calls getsockopt(SO_ERROR). This also explains why earlier attempts to reproduce the problem outside PHP were often fast: the overhead between calls is enough to step past the window.
  • The poll emulation suspected in #78825 is not the cause: select() returns in microseconds.

PHP can't avoid this race while it keeps calling getsockopt(SO_ERROR) right after the connect completes. On the success path, though, it doesn't need to make that call.

Proposed fix

On Windows, php_network_connect_socket() already polls for POLLOUT|POLLPRI. As the existing comment explains, Winsock reports a successful non-blocking connect in writefds and a failed one in exceptfds. So when the poll result has POLLOUT and no POLLPRI, the connect has succeeded and getsockopt(SO_ERROR) can be skipped:

--- a/main/network.c
+++ b/main/network.c
@@ -418,6 +418,13 @@ PHPAPI int php_network_connect_socket(php_socket_t sockfd,
 			ret = -1;
 		} else if (n == 0) {
 			error = PHP_TIMEOUT_ERROR_VALUE;
+#ifdef PHP_WIN32
+		} else if ((n & POLLOUT) && !(n & POLLPRI)) {
+			/* Writable and not in exceptfds: the connect succeeded (see the
+			 * comment above). Skip getsockopt(SO_ERROR) here, as calling it
+			 * right after the connection completes can block for 10-25ms. */
+			error = 0;
+#endif
 		} else {
 			len = sizeof(error);
 			/* BSD-derived systems set errno correctly.

The failure path is unchanged, because POLLPRI still goes through getsockopt(SO_ERROR).

I tested the patch on a local 8.5.11 NTS x64 build:

  • Successful connects: the expected output above. On loopback, the median went from 1.1 ms to 0.19 ms and p95 from 22.4 ms to 0.43 ms.
  • Refused and timed-out connects: the same errno and the same timing as the unpatched build.
  • Data transfer: reads and writes on the resulting stream work as before, and stream_get_meta_data()['blocked'] is unchanged.

PHP Version

PHP 8.5.11 (cli) (built: Sep 22 2026 13:51:38) (NTS Visual C++ 2022 x64)
Copyright (c) The PHP Group
Built by The PHP Group
Zend Engine v4.5.11, Copyright (c) Zend Technologies
    with Xdebug v3.5.3, Copyright (c) 2002-2026, by Derick Rethans
    with Zend OPcache v8.5.11, Copyright (c), by Zend Technologies

Operating System

Windows 11 Pro 10.0.26300 (also seen on Windows 10 and Windows Server 2019, see #78825)

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions