EPIPE on write might mean you're doing it wrong
Last month, I had an opportunity to dip into a part of the world I don't normally touch: Apache (as in, the web server) and the way it runs PHP code. This might seem ironic to some people since I used to support a colossal amount of PHP-ish code, but that was done with a virtual machine and had long since evolved out of the Apache ecosystem.
Yep, this is about Apache, php-fpm, mod_fastcgi, and all of that other stuff, and it was all new to me. The question was basically: why is this getting plugged up sometimes? The rest of the machine seems fine, so why is this one part going stupid?
There was a lot of random badness I ended up tripping over, but there was one part in particular I wanted to call out for the benefit of anyone who still gives a shit about trying to build this stuff properly. It has to do with being aware of the situation and not smacking yourself in the head with a shovel if you can help it.
In the above regime, a request comes in, and the web server pushes it down a Unix domain socket to a bunch of PHP worker threads which are hanging out in accept(). One of them "wins" and is rewarded with a new file descriptor for the incoming request.
The next thing it does (and this is fine) is to call poll() because it wants to wait until there's some work to be done. It immediately returns, but it has two flags set, not one. It has POLLIN, sure, but it also has POLLHUP which basically means that the far end has already gone away. It doesn't notice this second flag.
What does it do? If you said "it reads the request and runs it anyway, then sends a response to an uncaring socket that has nobody on the other end", you're right. It gets EPIPE on the write, and presumably some other part of the process gets a SIGPIPE, assuming it hasn't squashed them in its signal setup.
Let's say these requests are relatively expensive and take up to 5 seconds to run. Maybe there are 300 of them stuck in the pipe, having already been transmitted by the web server, but both it and the original http client are long gone. The fpm workers are still going to pop each one off in turn, will blow multiple seconds doing the work, and will then reply to nobody in particular.
It'll take at least 1500 worker-seconds for it to dig out from this backlog. If there are 10 workers, well, that's at least 150 seconds of it being completely saturated and thus unable to run PHP stuff for any part of the site which ends up on these workers. Good times.
This is what it looks like when this happens with multiple threads:
1672855 01:09:05.333624 write(5, "\1\6\0\1\0A\7\0Content-type: text/html; charset=UTF-8\r\n\r\nstuff... more stuff...\n\0\0\0\0\0\0\0\1\3\0\1\0\10\0\0\0\0\0\0\0\0\0\0", 96) = -1 EPIPE (Broken pipe) <0.000028> 1672853 01:09:05.335072 write(5, "\1\6\0\1\0A\7\0Content-type: text/html; charset=UTF-8\r\n\r\nstuff... more stuff...\n\0\0\0\0\0\0\0\1\3\0\1\0\10\0\0\0\0\0\0\0\0\0\0", 96) = -1 EPIPE (Broken pipe) <0.000027> 1672854 01:09:06.307556 write(5, "\1\6\0\1\0A\7\0Content-type: text/html; charset=UTF-8\r\n\r\nstuff... more stuff...\n\0\0\0\0\0\0\0\1\3\0\1\0\10\0\0\0\0\0\0\0\0\0\0", 96) = -1 EPIPE (Broken pipe) <0.000028>
That's my dumb little test script which prints "stuff... more stuff..." being run by the fpm worker and finding out that the requester is gone. This was many minutes after I stopped sending new requests.
This is how you get a system that will stay sick long after the crushing load of requests has gone away. Again, it could have known this at the very beginning...
01:20:36.514261 poll([{fd=5, events=POLLIN}], 1, 5000) = 1 ([{fd=5, revents=POLLIN|POLLHUP}]) <0.000016>
It's right there! POLLHUP! It's gone! The poll loop in question is itself buried maybe six or seven levels deep in a massively nested set of while loops, if-this, if-that, etc, and amounts to this:
do {
errno = 0;
ret = poll(&fds, 1, 5000);
} while (ret < 0 && errno == EINTR);
if (ret > 0 && (fds.revents & POLLIN)) {
break;
}
It just sits there in poll for 5 seconds, ignoring the usual worse-is-better EINTR stuff, until poll says something is ready to rock or it gets a "real" error, or it times out.
But if poll said something went active and POLLIN is set, then it breaks out of whatever "while (1)" loop it's in (seriously), and carries on to where it reads from the socket and fires up the parser and runs some script with the parameters. That's what you'd expect.
I wondered what it would do if I forced it to abort any request where POLLHUP was set on the client fd. It should just drop straight through and clean up the mess relatively quickly, and sure enough, it did:
3756041 04:09:59.049764 poll([{fd=5, events=POLLIN}], 1, 5000) = 1 ([{fd=5, revents=POLLIN|POLLHUP}]) <0.000016>
3756041 04:09:59.049827 close(5) = 0 <0.000024>
3756041 04:09:59.049888 accept(10, {sa_family=AF_UNIX}, [112 => 2]) = 5 <0.000030>
3756041 04:09:59.049972 fcntl(5, F_GETFD) = 0 <0.000013>
3756041 04:09:59.050021 fcntl(5, F_SETFD, FD_CLOEXEC) = 0 <0.000013>
3756041 04:09:59.050070 poll([{fd=5, events=POLLIN}], 1, 5000) = 1 ([{fd=5, revents=POLLIN|POLLHUP}]) <0.000015>
3756041 04:09:59.050130 close(5) = 0 <0.000023>
3756041 04:09:59.050190 accept(10, {sa_family=AF_UNIX}, [112 => 2]) = 5 <0.000019>
3756041 04:09:59.050251 fcntl(5, F_GETFD) = 0 <0.000013>
3756041 04:09:59.050300 fcntl(5, F_SETFD, FD_CLOEXEC) = 0 <0.000013>
3756041 04:09:59.050349 poll([{fd=5, events=POLLIN}], 1, 5000) = 1 ([{fd=5, revents=POLLIN|POLLHUP}]) <0.000014>
3756041 04:09:59.050407 close(5) = 0 <0.000021>
3756041 04:09:59.050465 accept(10, {sa_family=AF_UNIX}, [112 => 2]) = 5 <0.000019>
3756041 04:09:59.050523 fcntl(5, F_GETFD) = 0 <0.000024>
3756041 04:09:59.050579 fcntl(5, F_SETFD, FD_CLOEXEC) = 0 <0.000007>
3756041 04:09:59.050616 poll([{fd=5, events=POLLIN}], 1, 5000) = 1 ([{fd=5, revents=POLLIN|POLLHUP}]) <0.000011>
Just... bonk, bonk, bonk, bonk. The first worker that wakes up ends up stripping all of these useless requests from the queue. It doesn't spend any time reading them, never mind parsing them, and it definitely doesn't try running any PHP code. It just closes the fd and goes back to waiting for another connection.
It managed to destroy a backlog of a few thousand dead requests in under a second. By not wasting time on those things, it became available to handle the real requests from the clients who were still connected to the web server right then.
Now, is this a universal solution? Probably not. There are plenty of async situations where you'll get POLLHUP and you still need to read until EOF because you care about consuming whatever data managed to make it down the chute before the far end hung up. Case in point: do the fork, dup2, exec thing to run a subprocess and watch the fds for stdout and stderr from the child process. poll will totally see POLLHUP once it's done, but you have to stick around to consume the rest of it. You did want the actual output from the process, right?
But, if you're talking about individual webshit requests that are probably going to be retried anyway and which don't need to be executed "at any cost" because there's nothing unique or special about them, you could probably stand to slough off the load up front.



