-
Notifications
You must be signed in to change notification settings - Fork 2.3k
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
buffer: Use a double-buffering scheme to prevent data races #943
Conversation
You may not understand the golang/go@651ddbd correctly.
But from Go 1.10 |
@methane: Apologies for the confusion, I've written Let me know if this makes more sense to you. |
@methane: I've updated the test as you requested. Could we now please discuss the proposed fix in this PR? |
@methane: I've reduced the size of the buffers in the test and updated the comments. 😄 |
@methane: I added the |
Can this be more selective on when flip() is called? As mysqlRows.Close() knows when it has to discard/corrupt the buffer. Then it seems would not have to allocate the bg buffer in newBuffer(), as fill() will allocate one if needed? |
@renthraysk: We are already being quite selective on when Because of the way the
In this case, when we call Now, besides that, it is true that we don't have to allocate the background buffer when we create the |
@methane: As @renthraysk points out, we don't need to allocate the background buffer upfront. I've removed the allocation, so in the usual case of all |
@vmg Ah, was just looking at the diff of Close() with just the flip() call showing, without the early exits. Doh! |
@vmg Please don't use rebase so much. It's difficult to see what's changed after I saw last. |
@methane Understood. Sorry about that. I'll push new commits instead of rebasing. |
@methane: I've added your benchmark to this PR and increased the maximum buffer size. With a max buffer of 256k, and the optimization where we don't allocate the background buffer unless needed, no extra memory is allocated during the benchmark:
I'm not sure of what would be the ideal value for |
Co-Authored-By: vmg <[email protected]>
Awesome. I think this is a very good, simple fix and we've proven it doesn't regress performance. Who else needs to approve this PR? 😄 |
In our workflow, only one core member review is required. When core member creates PR, they |
🙇🙇🙇 |
@vmg Thanks a lot for this pull-request! |
Description
This is a potential fix for #902, #903. Some context:
The Go 1.10 release changed the API contract for
driver.Rows.Next
insql/driver
. Previously, we could modify the backing storage behind the[]Value
slice that is passed to our driver'sdriver.Rows.Next
function when the user calls thesql.Rows.Close
method from the public interface. This is no longer the case: we can no longer modify this backing storage outside of thedriver.Rows.Next
call, or this will lead to a data race.Although in theory,
sql.Rows.Scan
andsql.Rows.Close
are always called sequentially from user's code, there is a corner case withcontext
cancellation: if you've performed a query withQueryContext
and yourContext
is cancelled or timeouts while reading theRows
result of that query,Rows.Close
can be called simultaneously withsql.Rows.Scan
, which is where this data race happens.Now, why are we modifying the backing storage we return from
driver.Rows.Next
? This is a complex issue arising from a design constraint in MySQL's protocol. When the Gosql
package cancels an on-flight query, before this query's connection can be re-used to perform any more queries, we must "drain" it, i.e. read all the remaining query results until an EOF packet that may have already been sent from MySQL. This draining is a complex operation, because although most of the drained data is discarded, we still need to read packet headers to figure out the size of each incoming packet, and we still have to read theEOF
(and any potentialERR
) packets whole, so we can keep track of our connection status.This means that draining a connection after a query writes data to our connection buffer, by necessity. When the
db/sql
package is performingScan
on the fields we've returned fromdriver.Rows.Next
, these fields are often backed by our connection buffer, so any writes to it will corrupt theScan
values. This is a serious vulnerability that needs to be addressed asap.I have spent some time evaluating all possible fixes for this issue, and I believe the fix proposed here has the best ratio between complexity and extra memory usage. Let us review the other options I've discarded:
Truncate & allocate a new buffer during the
Close
call: this has been implemented by @methane in Truncate internal buffer when rows.Close() is called #904 and rejected by @julienschmidt. I do agree with Julien that allocating a new buffer per close is too expensive. It can cause significant memory churn in the library.Delay the discard operation on the connection: since we do not know how long after a
Rows.Close
call is it safe to start reading from the connection and writing to our buffer to "drain" the remaining rows, it could in theory be possible to simply "flag" the connection with a "needs draining" flag onClose
, and then performing the draining on the nextQuery
. I don't think this is a good idea because it could add arbitrary latency from a previous query to the following one, and because it's a bit scary to leave MySQL hanging with packets; it could lead to connection timeouts.Discard the remaining packets without writing to the connection buffer: I have sketched an implementation of this here. I am wary of this approach because discarding packets straight from the wire introduces a lot of extra complexity: it is not possible to perform the discarding without using some scratch storage to read
EOF
andERR
packets, so we need to allocate a small discard buffer for each connection (64 bytes). The code to switch between the discard and packet-read buffers is complex. Although the resulting performance is very good (better than before the fix, because we're discarding large packets directly instead of allocating and returning them), and the fix does not allocate extra memory, I believe the complexity trade-off is not worth it.Use a double-buffering scheme: this is the solution proposed in this PR. It's far from a novel idea. Double-buffering has been used for decades in graphic buffers to, huh, avoid data races! It consists on allocating two buffers and switching between them once "data" has been returned to the user (in this case, "data" is the contents of the Rows to scan; in Graphics "data" is a rendered frame). There is an obvious upfront memory cost, which is that each connection requires two connection buffers (so an overhead of 4kb per connection), but once we've allocated the foreground and background buffers on a new connection, the performance is great, there are no further memory allocations, and the code to swap between the buffers is minimal.
@julienschmidt: I know you're wary of increasing memory usage in the library but I'd urge you to review this PR. The included stress test (
TestContextCancelQueryWhileScan
) proves there's a very real race here that can lead to data corruption, and the overhead cost for this fix is amortized throughout the lifetime of each connection. As explained, the code in this branch performs the discarding with allocating significantly less memory, but I don't think the complexity trade-off is worth it.@methane: Please do review this PR. I hope you'll agree this is a good compromise for a simple but relatively efficient fix for this serious issue.
Checklist