-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Array#map
is vulnerable to concurrent mutation
#15161
Comments
Actually modifying an array while iterating it has very few if any actual use cases (or the issues would have shown up a lot earlier). Could it be considered to instead simply forbid modifying the array during iteration? I feel it would be a shame to pay a performance price for something that isn't really how people normally use the thing. One way to implement that would be to use an rw_lock, though perhaps with a variant that raises if it fails to grab the write lock immediately. Upside: Would cost a lot less performance as it is only done once per iteration Thoughts? |
This is definitely worth considering. Such a protection must cover all operation that can potentially invalidate the buffer range. That's most mutating methods on |
As outlined by @BlobCodes, Java throws a ConcurrentModificationException exception when it detects a concurrent iterate/mutate situation (no guarantee: it's a best effort). |
This issue has been mentioned on Crystal Forum. There might be relevant details there: https://forum.crystal-lang.org/t/safety-guarantees-of-stdlib-data-structures/7364/17 |
The mention of |
Golang detects concurrent modification on |
Considering that both Go and Java have a protection mechanism to detect concurrent modifications in generic data structures, the trade-off seems to be worthwile. A major benefit is of course that it also triggers at least for some types of parallel modifications. So it would have a relevant effect thread safety, to error gracefully instead of ending up with corrupted data or a segfault. |
Digging a bit into the mechanisms in Go and Java it turns out their implementations - and apparently purposes - are quite different. Go uses a boolean flag which indicates whether a modification is currently happening. It's set via XOR which according to the doc comment should make it likely that if two fibers both try to mutate simultaneously, both notice the collision. This protects the integrity of the data structure itself, but not any assumptions about structural stability as would be necessary for the Java on the other hand uses a variable I suppose this could also be used to detect simultaneous modifications, at least after the fact: Before a method does a structural modification, it takes a snapshot of the current It might be possible to combine both approaches into a single variable, a modification counter where one bit indicates whether someone is currently writing. 🤔 |
Thanks for the description of approaches in Go and Java. They sound nice. I think the approaches that detect concurrent modification after the fact are just fine. |
At some point I was trying to make Array MT safe without locks in master...bcardiff:crystal:mt/safe-array#diff-a12e3cba63cd83b9e746cd9eab9d85f5ddebd05b696133cd494aa83174a48d16R55 . The idea was to decouple the array buffer from array itself. Each operation like #map would work on the initial buffer. Concurrent modifications would essentially be modifying potentially different buffers. That would prevent crashing but is a silent error which as discussed are hard to track. Maybe we can add a The linked code also includes some GC integrations I think I based from Java. To instruct a bit more precisely the GC regarding pointers in buffers. Not the main aspect, but in case someone checks that code. Also it was done before some refactors of the Array internal to make If I were to do the same again I would try to leave out the GC performance, and the introduction of Array::Buffer is essentially another Array-like structure that grows by doing realloc and does not have pointer redirection (like String that is size + payload inlined). Then the gist is refactoring Array to use a buffer instead of a pointer and updating to a new buffer in some operations (but maybe with |
I think the goal should include that using array concurrently should never cause an unsafe buffer access (freed buffer space, uninitialized buffer space, anything that could be conceivably be attacker controlled, etc.) but also try and flag occurrences to code authors like go/java. The former could possibly be true already, but I'm not sure. |
I feel like we're starting to agree on something: avoid exploitable segfaults + best effort to loudly detect errors. Are we? @RX14 We currently use We could replace it with a manual realloc (malloc/memcpy) and let the GC collect the old pointer. Since the buffer can only ever grow (never shrink) then iterations should be safe AFAIK... There still remains the issue that @bcardiff mentioned: we may remove entries in parallel, in which case we clear the memory (to avoid leaking memory) then we may iterate a null pointer 💣 Maybe that's a good argument towards a semi-precise GC that would know which bounds of the buffer should be scanned (bonus effect: no need to clear anymore) 🤔 |
I think accessing cleared memory cast to i.e. a safe |
This might actually happen due to an entirely unrelated issue, unless |
Array#map(&)
yields each element to transform it in the block. If the block performs mutating operations on the array, it can lead to invalid memory access or corrupted data.The same applies to
Array#map_with_index
, and I mean both when I refer to#map
.This is discussed as an example in https://forum.crystal-lang.org/t/safety-guarantees-of-stdlib-data-structures/7364
A simple reproduction of an example with data corruption:
The cause is that
Array#map
expectssize
to be static during its runtime. While this is generally a reasonable expectation, it's impossible to exclude that the block mutates the array's size.Array#map
is an optimized override. The base implementation ofEnumerable#map
does not have this bug because it does not assume any size. It grows the buffer on demand.The easiest solution is to drop the implementation of
Array#map
in order to fall back toEnumerable#map
. We can still optimize a bit by using the current size as size hint because usually it's gonna be stable.The text was updated successfully, but these errors were encountered: