-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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
Use workers instead spawning goroutines for each incoming DNS request #664
Conversation
Codecov Report
@@ Coverage Diff @@
## master #664 +/- ##
==========================================
+ Coverage 58.05% 58.12% +0.07%
==========================================
Files 37 37
Lines 9999 10026 +27
==========================================
+ Hits 5805 5828 +23
- Misses 3144 3149 +5
+ Partials 1050 1049 -1
Continue to review full report at Codecov.
|
75bb3d2
to
43210a0
Compare
4b44e42
to
de0cd6f
Compare
server.go
Outdated
select { | ||
case srv.queueTCPConn <- w: | ||
default: | ||
srv.spawnWorkerTCP() |
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 will lead to unbounded goroutine growth. Maybe that’s not a problem in reality, but even a small burst of requests would keep a large number of goroutines forever.
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.
BTW we have unbounded goroutine growth already in current implementation.
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.
@UladzimirTrehubenka Not strictly true, currently the goroutines will be garbage collected once they've served the request. These ones will be kept around forever.
Consider an constant load of 1req/s with a sudden burst of 100req/s for 1s:
- Currently one routine will be created per request leading to a peak of 100 goroutines that are then garbage collected.
- This pull request will cause 100 goroutines to be active forever.
The above would be particularly bad if a server was swamped with a very large number of bogus requests as in a DDOS situation, because the memory load can't be reclaimed.
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.
Added workers limit (10000) and exit for idle worker
server.go
Outdated
case srv.queueTCPConn <- w: | ||
default: | ||
srv.spawnWorkerTCP() | ||
srv.queueTCPConn <- w |
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.
There’s a race condition here. If another request comes in and reaches the select
statement above before this line is reached, it may steal the new worker.
A solution to this is simple, pass w
directly to spawnWorkerTCP
. If the argument is non-nil, it can be handled before the for range
loop.
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.
Don't follow about race - this is one thread and srv.queue is unbuffered channel,
so we cannot reach select
until we push the job to the queue.
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.
@UladzimirTrehubenka It's a race with a separate request that hits the select
. The spawning and sending are not atomic operations as a set.
This isn't a correctness issue, but it could lead to hard to diagnose latency spikes.
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.
@UladzimirTrehubenka You're dead right actually, ignore me. I didn't realise this was only ever called from one goroutine.
You could still see, an admittedly small, performance improvement by skipping the channel send as I suggested.
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.
Done
Note that I believe the initial impetus for this is that is faster? I can't tell because the PR description and the commit description are pretty thin...
Which is what we do know, short lived go routines handling a request; that we somehow need to bound this.. is because of UDP and spoofing? There is another issue open that says that go 1.11 will support more socket option making the increased speed argument less convincing. I'll take a short peek at the PR none the less. |
server.go
Outdated
@@ -12,9 +12,6 @@ import ( | |||
"time" | |||
) | |||
|
|||
// Maximum number of TCP queries before we close the socket. | |||
const maxTCPQueries = 128 |
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 now not done anymore?
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.
I don't follow why we need to limit TCP queries. If we have constant load between two CoreDNS it is very expensive to drop connection on reach maxTCPQueries (say after each 128 packet first CoreDNS does reconnect to second CoreDNS).
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.
Dropping this limit would be best done as a separate pull request IMO, as it's orthogonal to 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.
Revert back maxTCPQueries - will remove in separate PR
server.go
Outdated
srv.spawnWorkerUDP() | ||
} | ||
|
||
func (srv *Server) spawnWorkerTCP() { |
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.
you can just call go spanWorkerTCP
no need to do this in this function
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.
Done
server.go
Outdated
// Shutdown handling | ||
lock sync.RWMutex | ||
started bool | ||
} | ||
|
||
func (srv *Server) spawnWorkerUDP() { | ||
go func() { |
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 anonymous func
should be a named func
so it shows up in panics and is clearer to debug.
Replace it with something like func (srv *Server) workerUDP() { for range ... }
and then replace srv.spawnWorkerUDP()
with go srv.workerUDP()
.
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.
Done
@UladzimirTrehubenka Do you have any benchmarks for this change? I can think of a few alternative implementations that won't suffer from the unbounded growth problem, but it's hard to compare without numbers. |
1c00dd3
to
0ce1c8a
Compare
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.
I happen to really like this approach, but I think it does need to be justified by some benchmarks (gentle ping).
server.go
Outdated
func (srv *Server) worker(w *response) { | ||
workersCount := atomic.LoadInt32(&srv.workersCount) | ||
if workersCount > maxWorkersCount { | ||
w.Close() |
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 still call srv.serve(w)
. The goroutine has already been created, there’s no reason to drop this request on the floor.
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.
So need to move workers count check outside worker func - otherwise if we call srv.serve(w) - it will work as original CoreDNS - say spawn goroutine for each request.
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.
That works to, it depends whether it should be a hard limit on all goroutines or only a hard limit on alive goroutines.
Personally I like this approach more because performance can’t be worse than previous. If you put it outside the goroutine and remove maxTCPQueries
, there will be an exploitable DOS vector.
server.go
Outdated
w.Close() | ||
return | ||
} | ||
atomic.AddInt32(&srv.workersCount, 1) |
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.
It’s not hugely important, but this has a race with the LoadInt32
above.
This should be written something like:
for {
count := atomic.LoadInt32(...)
if count > ... {
...
return
}
if atomic.CompareAndSwapInt32(..., count, count + 1) {
break
}
}
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.
Done
server.go
Outdated
@@ -295,26 +303,76 @@ type Server struct { | |||
DecorateReader DecorateReader | |||
// DecorateWriter is optional, allows customization of the process that writes raw DNS messages. | |||
DecorateWriter DecorateWriter | |||
|
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.
Leave the new line seperating public from private.
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.
Done
server.go
Outdated
break LOOP | ||
} | ||
count = 0 | ||
timeout = time.After(idleWorkerTimeout) |
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.
It should be safe to call timeout.Reset(...)
here paired with timer.NewTicker
(? - from memory) instead of creating a new timer each iteration.
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.
Done with Ticker - don't follow about Reset()
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.
I was thinking of time.NewTimer
+ Reset
- I didn’t check the docs.
I don’t think time.NewTicker
is right here, because it drops channel sends on the floor, meaning this would be sutbley wrong.
server.go
Outdated
timeout = time.After(idleWorkerTimeout) | ||
} | ||
} | ||
atomic.AddInt32(&srv.workersCount, -1) |
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.
Use a defer
and pair it with the atomic operation above. The overhead of defer
is negligible and it keeps correctness clearer.
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.
Done
server.go
Outdated
// ListenAndServe starts a nameserver on the configured address in *Server. | ||
func (srv *Server) ListenAndServe() error { | ||
srv.lock.Lock() | ||
defer srv.lock.Unlock() | ||
if srv.started { | ||
return &Error{err: "server already started"} | ||
} | ||
|
||
if srv.Handler == nil { | ||
srv.Handler = DefaultServeMux |
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.
I’d move this conditional into srv.serveDNS
, there’s no need to modify srv
here or below.
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.
I purposely moved this code from serveTCP/serveUDP because this code should be called only once on server start - I totally don't understand why we need to call this code on each serveDNS (and another one gap in original code - send handler as argument instead using srv.Handler).
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.
It’s not wrong by any means, but it’s very much not idiomatic. Go code like this very rarely mutates public fields. (This is the net/http
case: https://golang.org/src/net/http/server.go?s=82393:82459#L2676).
Also a single if statement will have zero effect on performance.
(You don’t need to pass it in as an argument like it was before, but you can. It should be just put in serveDNS
).
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.
Done
@tmthrgd agreed it needs benchmarks, but this will also fix a silent crash that happens when too many go routines are spawned. |
83a18b6
to
996c46c
Compare
server.go
Outdated
srv.serve(w) | ||
|
||
count := 0 | ||
ticker := time.NewTicker(idleWorkerTimeout) |
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 be time.NewTimer
with ticker.Reset
below. time.NewTicker
will drop sends on the floor.
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.
Done
server.go
Outdated
default: | ||
} | ||
|
||
for { |
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 doesn’t work as well here and dropping requests on the floor is very much the wrong approach. It leads to trivial DOS attacks against the TCP server in particular which would be made worse by removing maxTCPQueries
.
Either move this back into worker
with a serveDNS
before w.Close
or replace the w.Close
below with a blocking send.
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.
If we move check to worker() - it means that maxWorkersCount makes no sense.
10000 workers are busy and we continue spawn goroutines.
We cannot prevent DOS in any case - the difference is that your proposal doesn't prevent out of resources - but current code does.
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 approach has a trivial DOS vector, the existing code doesn’t which is the key point to me. The existing code, and my suggestion to move the check, will scale as far as the host machine will. This code has an artificial hard limit of 10000, which is arbitrary. There are systems which can scale farther than that. The maxWorkersCount
would still have value because it would limit the long-lived goroutines.
Some quick numbers: _StackMin
is 2KiB meaning 10,000 goroutines uses 2048*10000/2^20 = 19GiB
of memory for stack. That’s too large for many systems and far too small for many others.
I like the approach of the check being in the goroutine because it saves the overhead of creating and destroying goroutines, but doesn’t limit the servers maximum performance. This one just drops requests on the floor which feels very wrong to me.
I think conflating resource control with long-lived worker goroutines is a mistake, because they’re too distinct issues.
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.
Done
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.
BTW 2KiB * 10000 ~ 20 MiB not 20 GiB
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.
Whoops @ my math.
Regarding benchmark - I am not sure that go benchmark is useful. |
996c46c
to
53d2807
Compare
@UladzimirTrehubenka Those are exactly the sort of benchmarks I was hoping to see. |
53d2807
to
bb0e09e
Compare
@miekg just a friendly reminder: could you provide some feedback? |
@tmthrgd already took a look, so that's good. I want echo the sentiment having a benchmark test will help - I haven't had time to follow the entire discussion. Taking a look now. |
checked out the branch, seeing the same increase @UladzimirTrehubenka also saw, pasting here:
|
pprof: (don't fully understand but lower number, second one is with this applied)
patched:
|
diff looks pretty minimal, don't have any major concerns. @tmthrgd this good to go? |
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 looks sensible and good to me.
server.go
Outdated
|
||
defer atomic.AddInt32(&srv.workersCount, -1) | ||
|
||
count := 0 |
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.
There's no need for an actual counter here. Replace this with a simple bool
and set it to true
in the <-srv.queue
case and to false
in the <-timeout.C
case.
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.
Done
@miekg Aside from one small nit, this LGTM. 👍 |
[ Quoting <notifications@github.com> in "Re: [miekg/dns] Use workers instead..." ]
@miekg Aside from one small nit, this LGTM. 👍
nice! Ok, let's wait for that and I'll merge.
Thanks for the effort here, the both of you!
|
probably mark this 1.0.6 soon. |
In a simple test of GeoDNS on my mac the qps went up from ~29k to ~37k qps with this change; nice work! (Specifically I was testing from the commit before this change was merged to whatever is the latest now; and I didn't do anything to make other things not run at the same time, configure the network stack to make sure it wasn't a bottleneck, etc etc). |
No description provided.