gh-102494: fix MemoryError and data race in devpoll based selector on Solaris - #102495
gh-102494: fix MemoryError and data race in devpoll based selector on Solaris#102495kulikjak wants to merge 16 commits into
Conversation
|
For reference, documentation at https://docs.oracle.com/cd/E19253-01/816-5177/6mbbc4g9n/index.html for example. Notice that current array plays two roles:
Instead of allocating a huge array and clap it to a max size, I would suggest to keep a count of active file descriptors (registrations minus unregistrations) and resize the array as needed (maybe only growing it if needed, never shrinking it). I would suggest an initial size of 1024, growing 25% when needed. Shrinking would be nice too, but probably unimportant. (*) Would be quite interesting to investigate the kernel implementation. I guess that playing with a handful of fds would be enough to determine if the kernel gives back the active fds in a round robin, random or "start from the beginning until you fill the buffer" way. Some comments at https://github.com/illumos/illumos-gate/blob/2c76d75129011c98e79463bb84917b828f922a11/usr/src/uts/common/io/devpoll.c#L237 suggest that Solaris kernel gives back the active descriptor in a round robin way, so actually using a small (1024 entries?) buffer would be fine enough. See also code around line 293 and 629. This assumption needs to be tested, but the intention seems quite clear. If this assumption holds true and you want a highly concurrent/performant implementation, you could resize the array when the returned list of active file descriptors use the full array, signaling that a bigger array could reduce syscall traffic, although if you are dealing with Python, this kind of optimizations are probably overkill. PS: I would support a "port" interface, although Solaris is not a tier-1 platform nowadays for Python. |
|
Thank you for the detailed notes; I will look into it. I've also recently hit another issue with the Devpoll selector when working on Cheroot (cherrypy/cheroot#561), so it needs some love. |
|
Any progress? I support @jcea's idea. |
|
Uh, I am sorry - this one's gone missing from my todo list... Thanks for the review and extensive notes. Originally, I was under the assumption (I think - it's quite some time since I filled this) that we need as big of an array as is the number of watched descriptors, but we indeed don't. I played with devpoll a little and the active file descriptors are indeed returned in a round robin way (at least on Oracle Solaris), so we don't need to worry about starvation. I wonder whether we even need to resize the array. The performance would probably be slightly better in cases where We can also provide a new method to resize the buffer - most people would likely be happy with |
|
And as for the port interface, I'll keep that in mind. It certainly doesn't have the highest priority, and thus I don't know when I'll have some time to look into that, but it would be a nice addition. Thanks! |
|
So, I found the root cause of the problem I wrote about above (cherrypy/cheroot#561), and coincidentally it's relevant here. In my testing, I saw It happens when Py_BEGIN_ALLOW_THREADS
errno = 0;
poll_result = ioctl(self->fd_devpoll, DP_POLL, &dvp);
Py_END_ALLOW_THREADSright after Forcing a Because of that, I split the buffer into two - one for polling and one for registering/unregistering. The one for polling now has a limit of The register/unregister buffer has no specific requirements and will work no matter the size (128 seems like a good size ;)). Let me know what you think. Thanks! |
serhiy-storchaka
left a comment
There was a problem hiding this comment.
Can we have any tests? Is this issue reproducible on OpenIndiana?
Devpoll is tested with all the selector tests from If you mean a test for this issue (having one buffer being used by two different operations), I don't know how feasible that is. As for OpenIndiana, I didn't test that, but they are using the same patch as we do: |
|
I just realized that this PR is no longer about just the MemoryError (how it started), but also includes the "two buffers" change, which is pretty unrelated. I wonder whether I should split it into separate issues/PRs? |
The amount of descriptors returned with select is not affected by FD_SETSIZE or RLIMIT_NOFILE when using devpoll - the limit is hardcoded to 1024 now.
|
One more thing - since devpoll select is not directory affected by |
|
This PR is stale because it has been open for 30 days with no activity. |
|
This is still relevant and I believe ready for review and push. We are already using this patch internally: |
|
@jcea, would you please check the changes so we can merge it? Thanks! |
I agree that allocating an "unlimited" amount of memory is a bad idea :-) But I'm not sure that the change is correct. I wrote the following script: import socket, select
#SIZE = 5
SIZE = 1050
read_fds = []
write_fds = []
for _ in range(SIZE):
s1, s2 = socket.socketpair()
read_fds.append(s1)
write_fds.append(s2)
p = select.devpoll()
for fd in read_fds:
p.register(fd, select.POLLIN)
#for fd in write_fds:
# p.register(fd, select.POLLOUT)
for sock in write_fds:
sock.send(b'abc')
print(f"expect {len(write_fds)} events")
events = p.poll()
print(f"got {len(events)} events")
for sock in read_fds:
sock.close()
for sock in write_fds:
sock.close()Output with this change on OpenIndiana: It seems like devpoll.poll() is now limited to 1024 events, even if there are more events available, and so some events are missed :-( I'm not sure that it's correct to allocate a fixed buffer of 1024 entries for "out fds". devpoll.register() should reallocate the buffer to the total number of registered file descriptors. |
|
That is indeed the case. The limit is currently 1024 events, but the rest is not forgotten or thrown away - if you process some of them, you can get the rest. The following added to your script: ....
events = p.poll()
print(f"got {len(events)} events")
for i in range(100):
read_fds[i].recv(100)
events = p.poll()
print(f"got {len(events)} events")results in: My thinking here was that the application handling events is likely running in a loop of " I see that the other selectors are apparently not doing this. With |
|
This works as long as the user won't start calling My other idea is to run the |
vstinner
left a comment
There was a problem hiding this comment.
The change now mostly LGTM. I just have a little concern about the getrlimit(RLIMIT_NOFILE) call in the constructor. Is it really useful to call it?
| out_size = limit.rlim_cur; | ||
| if ((rlim_t)out_size > DEVPOLL_OUT_BUFFER_SIZE) { | ||
| out_size = DEVPOLL_OUT_BUFFER_SIZE; | ||
| } |
There was a problem hiding this comment.
Is it really worth it to getrlimit(RLIMIT_NOFILE) if we limit the value to 128 anyway? I suggest to always use 128.
select.devpoll.poll() enlarges outsize on demand anyway.
|
Oh, the "Change detection / Create context from changed files" CI failed with "fatal: shallow file has changed since we read it". That's the issue gh-151365. I can fix it later. |
|
@kulikjak: I pushed changes to your branch. Would you mind to review them?
My worry is that maybe the first 1024 fds will "always" be ready, and so events on the following file descriptors may never be reached :-( It can introduce high latency which would depend on the file descriptor number, not good.
I started to write a patch for your PR, but then I noticed that you already wrote a fix 1 hour ago, great!
Hum, your So I took the liberty of pushing a fix: I added a set object to keep track of the exact number of registered file descriptors. In short, While testing my change for reference leak using I pushed a second change to fix test_devpoll leaks. I also changed the NEWS entry to add a link to select.devpoll(). |
|
I clicked on [Update branch] to fix the "fatal: shallow file has changed since we read it" error seen on the "Change detection / Create context from changed files" CI. |
serhiy-storchaka
left a comment
There was a problem hiding this comment.
I have concerns about re-entrancy. Any allocations in register() can start a GC pass, which can run arbitrary Python via finalizers -- including something that calls close() on this very selector.
| Py_ssize_t registered = PySet_GET_SIZE(self->registered); | ||
| if (registered > self->out_size) { | ||
| self->out_size = registered + 128; | ||
| PyMem_Resize(self->out_fds, struct pollfd, self->out_size); |
There was a problem hiding this comment.
This looks very suspicious. On failure, it leaks memory and leave the object in inconsistent state.
When you set
ulimit -n unlimitedon Solaris and its derivatives (where/dev/pollis available) and import selectors, Python crashes with aMemoryErrorbecause there is no upper limit to the allocation size.This fix adds an arbitrary limit of 2^18 (which results in roughly ~4MB of memory).
Fixes #102494
Edit: this also fixes the data race mentioned below: #102495 (comment)