-
Notifications
You must be signed in to change notification settings - Fork 12
Fix Read() behaviour; heavy performance optimisations; i/o deadlines; more #58
Conversation
- Read() used to behave like io.ReadFull(); it now behaves like io.Reader mandates. - preallocate 2-byte (long) slices for message length calculation. - on read, copy directly to supplied buffer, if the message is smaller or equal to len(buffer). - use buffer pools (via go-buffer-pool) to contain allocs and GC.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is such an improvement, thanks @raulk. I especially ❤️the updated comments.
Let's give @Stebalien the chance to review, and @aarshkshah1992 too if he feels like it! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice!
Main points:
- Avoid writing the length in a separate write. This has caused us a lot of trouble in the past.
- If possible, decrypt in-place. Many libraries support this.
handshake.go
Outdated
// set a deadline to complete the handshake, if one has been supplied. | ||
// clear it after we're done. | ||
if deadline, ok := ctx.Deadline(); ok { | ||
_ = s.SetDeadline(deadline) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Check the error. If deadlines aren't supported, we shouldn't try to revert the deadline.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also note: It may be better to just spin off a goroutine that closes the connection when the context closes (setting a deadline on the context itself). Otherwise, we're not going to obey the context.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also note: It may be better to just spin off a goroutine that closes the connection when the context closes (setting a deadline on the context itself). Otherwise, we're not going to obey the context.
Could you elaborate? Do you mean that if the context fires while we're not waiting on i/o, we wouldn't notice, and strictly speaking wouldn't yield until the next i/o operation? I think that's a trade-off I want to take, vs. introducing the extra complexity.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 711cc40; I added a TODO for potentially spinning off a goroutine if we can't set a native deadline.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The deadline will help if the context has a deadline. However, the user may just want to cancel the context. In that case, we can't set a deadline and the only solution is to wait on the context:
var conn, handshakeErr, doneCh
go func() {
defer close(doneCh)
// do handshake
}()
select {
case <-ctx.Done():
insecureConn.Close()
<-doneCh
return nil, ctx.Err()
case <-doneCh:
return conn, handshakeErr
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But we can do this in a new patch.
@raulk I do feel like it but need the time to grok and digest all the magic happening here. Please feel free to merge without waiting on my review if we are in a hurry to land this. In that case, I'll do a review after the merge and will create PRs myself to fix stuff that needs to be fixed. |
@Stebalien all done here. Final benchmarks vs. benchmarks before your review:
Not a lot has changed (maybe the compiler was already optimising for us?). Throughput is down a little (maybe an artefact). bytes up and down -- not a lot of difference. Effects may be more noticeable in real-world scenarios? Or have I done something wrong? |
Note: if the receive buffer is large enough, we can avoid allocating entirely on ready by:
But it may be best to punt that to a new PR. |
Merging this. There are definitely a few more optimisations that we can pursue, but they'll require further refactoring, which I don't have time to do now. Final benchmarks -- looks like my previous run had some artefacts, this is much better -- compared to master:
|
@Stebalien follow-ups captured here: #77. |
s.writeLock.Lock() | ||
defer s.writeLock.Unlock() | ||
|
||
writeChunk := func(in []byte) (int, error) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@raulk Why do we need to take this write lock given that there's no shared secureSession
state across these Write calls and golang's net.Conn allows concurrent writes ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because we encrypt then write. If we don't take a lock, two threads A, B could encrypt in order A, B, then end up writing B, A on the wire, which would make the stream ciphers fail.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, Write writes the entire incoming data, which could take several rounds if the data exceeds the maximum payload size. If two threads are writing at the same time, their chunk writes could intertwine.
size := int(binary.BigEndian.Uint16(buf)) | ||
buf = make([]byte, size) | ||
size := int(binary.BigEndian.Uint16(s.rlen[:])) | ||
buf := pool.Get(size) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@raulk For handshake messages, we never put this back in the pool. Wont that cause a leak ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should return to the pool, but sync.Pool
doesn't retain references to the elements it hands out (unlike pools in other languages), so this won't cause a leak as it will be GC'ed. But it's less than ideal, yeah.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmmm.. given that we can heavily re-use these pooled buffers for handshake messages (because messages in the same handshake stage are always of the same length), not returning these to the pool causes unnecessary GC/allocs. Will fix this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, please fix. It doesn't cause a leak, but it's suboptimal.
Improvements
make
Read()
conform toio.Reader
behaviour:Read()
used to behave likeio.ReadFull()
; it now behaves likeio.Reader
mandates.Heavy performance optimisation (see benchmarks and benchcmp below):
Fix handshake i/o operations not setting deadlines.
Remove PDFs from repo.
Wrap errors.
Refine and improve comments.
Benchmarks (master vs. this branch)
Fixes #57.
Fixes #75.
Fixes #76.