-
-
Notifications
You must be signed in to change notification settings - Fork 4k
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
lib/connections, lib/config: Bandwidth throttling per remote device (fixes #4516) #4603
Changes from 8 commits
bf78111
7ca0546
e486481
79f96e5
9b14e01
c1665b3
d175118
1070dd6
b52b326
ae58320
b6d723d
a33855f
8d637d6
5157154
cc63da2
36368f1
2ce47ad
f6a92d5
353983f
6967960
0298b9f
628ae2b
56b471f
da98cfe
a38f5ea
e160d1d
c3facc1
d03b6ff
291a198
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -12,53 +12,126 @@ import ( | |
"sync/atomic" | ||
|
||
"github.com/syncthing/syncthing/lib/config" | ||
"github.com/syncthing/syncthing/lib/protocol" | ||
"golang.org/x/net/context" | ||
"golang.org/x/time/rate" | ||
"sync" | ||
) | ||
|
||
// limiter manages a read and write rate limit, reacting to config changes | ||
// as appropriate. | ||
type limiter struct { | ||
write *rate.Limiter | ||
read *rate.Limiter | ||
limitsLAN atomicBool | ||
write *rate.Limiter | ||
read *rate.Limiter | ||
limitsLAN atomicBool | ||
deviceReadLimiters *sync.Map | ||
deviceWriteLimiters *sync.Map | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Although we could bump our requirements I think there should be little enough churn on these that we can actually use regular maps and locking... |
||
myID protocol.DeviceID | ||
mu *sync.Mutex | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just |
||
} | ||
|
||
const limiterBurstSize = 4 * 128 << 10 | ||
|
||
func newLimiter(cfg *config.Wrapper) *limiter { | ||
func newLimiter(deviceID protocol.DeviceID, cfg *config.Wrapper) *limiter { | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. empty |
||
l := &limiter{ | ||
write: rate.NewLimiter(rate.Inf, limiterBurstSize), | ||
read: rate.NewLimiter(rate.Inf, limiterBurstSize), | ||
write: rate.NewLimiter(rate.Inf, limiterBurstSize), | ||
read: rate.NewLimiter(rate.Inf, limiterBurstSize), | ||
myID: deviceID, | ||
mu: &sync.Mutex{}, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adjust here to nothing at all (standard lib |
||
deviceReadLimiters: new(sync.Map), | ||
deviceWriteLimiters: new(sync.Map), | ||
} | ||
|
||
// Get initial device configuration | ||
devices := cfg.RawCopy().Devices | ||
for _, value := range devices { | ||
value.MaxRecvKbps = -1 | ||
value.MaxSendKbps = -1 | ||
} | ||
prev := config.Configuration{Options: config.OptionsConfiguration{MaxRecvKbps: -1, MaxSendKbps: -1}, Devices: devices} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe the entire above block isn't necessary, as CommitConfiguration will add all missing devices anyway, so https://github.com/syncthing/syncthing/pull/4603/files#diff-da45d4f40dfa16a9aae760dd650fe79aL35 should be sufficient. |
||
|
||
// Keep read/write limiters for every connected device | ||
l.rebuildMap(prev) | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could just call rebuildMap here? |
||
cfg.Subscribe(l) | ||
prev := config.Configuration{Options: config.OptionsConfiguration{MaxRecvKbps: -1, MaxSendKbps: -1}} | ||
l.CommitConfiguration(prev, cfg.RawCopy()) | ||
return l | ||
} | ||
|
||
func (lim *limiter) newReadLimiter(r io.Reader, isLAN bool) io.Reader { | ||
return &limitedReader{reader: r, limiter: lim, isLAN: isLAN} | ||
// Create new maps with new limiters | ||
func (lim *limiter) rebuildMap(to config.Configuration) { | ||
deviceWriteLimiters := new(sync.Map) | ||
deviceReadLimiters := new(sync.Map) | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Noop space. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. empty |
||
// copy *limiter in case remote device is still connected, when remote device is added we create new read/write limiters | ||
for _, v := range to.Devices { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What does "v" stand for? We usually use "dev". |
||
if readLimiter, ok := lim.deviceReadLimiters.Load(v.DeviceID); ok { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What if there are two rebuildMap calls in parallel due to frequent config save or something like that, this might be halfway through a swap too. |
||
deviceReadLimiters.Store(v.DeviceID, readLimiter) | ||
} else { | ||
deviceReadLimiters.Store(v.DeviceID, rate.NewLimiter(rate.Inf, limiterBurstSize)) | ||
} | ||
|
||
if writeLimiter, ok := lim.deviceWriteLimiters.Load(v.DeviceID); ok { | ||
deviceWriteLimiters.Store(v.DeviceID, writeLimiter) | ||
} else { | ||
deviceWriteLimiters.Store(v.DeviceID, rate.NewLimiter(rate.Inf, limiterBurstSize)) | ||
} | ||
} | ||
|
||
// assign new maps | ||
lim.mu.Lock() | ||
defer lim.mu.Unlock() | ||
lim.deviceWriteLimiters = deviceWriteLimiters | ||
lim.deviceReadLimiters = deviceReadLimiters | ||
|
||
l.Debugln("Rebuild of device limiters map finished") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wouldn't it be better for rebuild to happen in place, this way getting rid of the lock that we have to get on every read and write. |
||
} | ||
|
||
func (lim *limiter) newWriteLimiter(w io.Writer, isLAN bool) io.Writer { | ||
return &limitedWriter{writer: w, limiter: lim, isLAN: isLAN} | ||
// Compare read/write limits in configurations | ||
func (lim *limiter) checkDeviceLimits(from, to config.Configuration) bool { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Again, doesn't need to live on lim. |
||
for i := range from.Devices { | ||
if from.Devices[i].DeviceID != to.Devices[i].DeviceID { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should blow up if a device is removed, as len(to) < len(from). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You should probably write a test case for this logic. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a nit-pick, but we don't really need to rely on the device order here. It's possible that rates aren't changed, just the order gets changed. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There should be some sort of cfg.DeviceMap() or something like that utility function. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, that should be moved from model to config: https://github.com/syncthing/syncthing/blob/master/lib/model/model.go#L2569 |
||
// Something has changed in device configuration | ||
lim.rebuildMap(to) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To be honest, this function as it states should only do checking, and not rebuilding (as a side-effect), and rebuilding and other cruft should happen in the CommitConfiguration. |
||
return false | ||
} | ||
// Read/write limits were changed for this device | ||
if from.Devices[i].MaxSendKbps != to.Devices[i].MaxSendKbps || from.Devices[i].MaxRecvKbps != to.Devices[i].MaxRecvKbps { | ||
return false | ||
} | ||
} | ||
return true | ||
} | ||
|
||
func (lim *limiter) newReadLimiter(remoteID protocol.DeviceID, r io.Reader, isLAN bool) io.Reader { | ||
return &limitedReader{reader: r, limiter: lim, isLAN: isLAN, remoteID: remoteID} | ||
} | ||
|
||
func (lim *limiter) newWriteLimiter(remoteID protocol.DeviceID, w io.Writer, isLAN bool) io.Writer { | ||
return &limitedWriter{writer: w, limiter: lim, isLAN: isLAN, remoteID: remoteID} | ||
} | ||
|
||
func (lim *limiter) VerifyConfiguration(from, to config.Configuration) error { | ||
return nil | ||
} | ||
|
||
func (lim *limiter) CommitConfiguration(from, to config.Configuration) bool { | ||
if from.Options.MaxRecvKbps == to.Options.MaxRecvKbps && | ||
if len(from.Devices) == len(to.Devices) && | ||
from.Options.MaxRecvKbps == to.Options.MaxRecvKbps && | ||
from.Options.MaxSendKbps == to.Options.MaxSendKbps && | ||
from.Options.LimitBandwidthInLan == to.Options.LimitBandwidthInLan { | ||
from.Options.LimitBandwidthInLan == to.Options.LimitBandwidthInLan && | ||
lim.checkDeviceLimits(from, to) { | ||
return true | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this global check is not very useful as global limits are reset even if just a device changed and whether device change is checked again later on. So I would either not check at all and just reset everything or check the different parameters separately. |
||
|
||
// A device has been added or removed | ||
if len(from.Devices) != len(to.Devices) { | ||
lim.rebuildMap(to) | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also, this is unnecessery if you handle it properly in checkDeviceLimits, or stop it from having side-effects. |
||
|
||
// The rate variables are in KiB/s in the config (despite the camel casing | ||
// of the name). We multiply by 1024 to get bytes/s. | ||
|
||
if to.Options.MaxRecvKbps <= 0 { | ||
lim.read.SetLimit(rate.Inf) | ||
} else { | ||
|
@@ -73,22 +146,55 @@ func (lim *limiter) CommitConfiguration(from, to config.Configuration) bool { | |
|
||
lim.limitsLAN.set(to.Options.LimitBandwidthInLan) | ||
|
||
// Set limits for devices | ||
for _, v := range to.Devices { | ||
if v.DeviceID == lim.myID { | ||
// This limiter was created for local device. Should skip this device | ||
continue | ||
} | ||
|
||
readLimiter, _ := lim.deviceReadLimiters.Load(v.DeviceID) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You should not throw away the ok, in case the value is not there. This is also a reason why rebuildMap should potentially do it without swapping the maps. |
||
if v.MaxRecvKbps <= 0 { | ||
readLimiter.(*rate.Limiter).SetLimit(rate.Inf) | ||
} else { | ||
readLimiter.(*rate.Limiter).SetLimit(1024 * rate.Limit(v.MaxRecvKbps)) | ||
} | ||
|
||
writeLimiter, _ := lim.deviceWriteLimiters.Load(v.DeviceID) | ||
if v.MaxSendKbps <= 0 { | ||
writeLimiter.(*rate.Limiter).SetLimit(rate.Inf) | ||
} else { | ||
writeLimiter.(*rate.Limiter).SetLimit(1024 * rate.Limit(v.MaxSendKbps)) | ||
} | ||
|
||
sendLimitStr := "is unlimited" | ||
recvLimitStr := "is unlimited" | ||
if v.MaxSendKbps > 0 { | ||
sendLimitStr = fmt.Sprintf("limit is %d KiB/s", v.MaxSendKbps) | ||
} | ||
|
||
if v.MaxRecvKbps > 0 { | ||
recvLimitStr = fmt.Sprintf("limit is %d KiB/s", v.MaxRecvKbps) | ||
} | ||
l.Infof("Device %s: send rate %s, receive rate %s", v.DeviceID, sendLimitStr, recvLimitStr) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This will always print stuff, even if someones rate hasn't changed since before. |
||
} | ||
|
||
sendLimitStr := "is unlimited" | ||
recvLimitStr := "is unlimited" | ||
if to.Options.MaxSendKbps > 0 { | ||
sendLimitStr = fmt.Sprintf("limit is %d KiB/s", to.Options.MaxSendKbps) | ||
} | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. separation doesn't seem helpful here There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. agree |
||
if to.Options.MaxRecvKbps > 0 { | ||
recvLimitStr = fmt.Sprintf("limit is %d KiB/s", to.Options.MaxRecvKbps) | ||
} | ||
l.Infof("Send rate %s, receive rate %s", sendLimitStr, recvLimitStr) | ||
l.Infof("Overall send rate %s, receive rate %s", sendLimitStr, recvLimitStr) | ||
|
||
if to.Options.LimitBandwidthInLan { | ||
l.Infoln("Rate limits apply to LAN connections") | ||
} else { | ||
l.Infoln("Rate limits do not apply to LAN connections") | ||
} | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We usually separate return with an empty line, so I'd not remove it here. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agree |
||
return true | ||
} | ||
|
||
|
@@ -99,50 +205,74 @@ func (lim *limiter) String() string { | |
|
||
// limitedReader is a rate limited io.Reader | ||
type limitedReader struct { | ||
reader io.Reader | ||
limiter *limiter | ||
isLAN bool | ||
reader io.Reader | ||
limiter *limiter | ||
isLAN bool | ||
remoteID protocol.DeviceID | ||
} | ||
|
||
func (r *limitedReader) Read(buf []byte) (int, error) { | ||
n, err := r.reader.Read(buf) | ||
if !r.isLAN || r.limiter.limitsLAN.get() { | ||
take(r.limiter.read, n) | ||
|
||
// in case rebuildMap was called | ||
r.limiter.mu.Lock() | ||
deviceLimiter, ok := r.limiter.deviceReadLimiters.Load(r.remoteID) | ||
r.limiter.mu.Unlock() | ||
if !ok { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To be honest, if !ok, I'd panic (or do nothing), as it should clearly be there. I'd do a if deviceLimiter != nil check in take |
||
l.Debugln("deviceReadLimiter was not in the map") | ||
deviceLimiter = rate.NewLimiter(rate.Inf, limiterBurstSize) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How can this happen? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It happens after removing remote device. I'm not sure about limitedWriter but I added it just to be safe. |
||
} | ||
take(r.limiter.read, deviceLimiter.(*rate.Limiter), n) | ||
} | ||
return n, err | ||
} | ||
|
||
// limitedWriter is a rate limited io.Writer | ||
type limitedWriter struct { | ||
writer io.Writer | ||
limiter *limiter | ||
isLAN bool | ||
writer io.Writer | ||
limiter *limiter | ||
isLAN bool | ||
remoteID protocol.DeviceID | ||
} | ||
|
||
func (w *limitedWriter) Write(buf []byte) (int, error) { | ||
if !w.isLAN || w.limiter.limitsLAN.get() { | ||
take(w.limiter.write, len(buf)) | ||
|
||
// in case rebuildMap was called | ||
w.limiter.mu.Lock() | ||
deviceLimiter, ok := w.limiter.deviceWriteLimiters.Load(w.remoteID) | ||
w.limiter.mu.Unlock() | ||
if !ok { | ||
l.Debugln("deviceWriteLimiter was not in the map") | ||
deviceLimiter = rate.NewLimiter(rate.Inf, limiterBurstSize) | ||
} | ||
take(w.limiter.write, deviceLimiter.(*rate.Limiter), len(buf)) | ||
} | ||
return w.writer.Write(buf) | ||
} | ||
|
||
// take is a utility function to consume tokens from a rate.Limiter. No call | ||
// to WaitN can be larger than the limiter burst size so we split it up into | ||
// several calls when necessary. | ||
func take(l *rate.Limiter, tokens int) { | ||
func take(l, deviceLimiter *rate.Limiter, tokens int) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the l could do with a better name now, and the doc string should be updated. |
||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. empty line |
||
if tokens < limiterBurstSize { | ||
// This is the by far more common case so we get it out of the way | ||
// early. | ||
deviceLimiter.WaitN(context.TODO(), tokens) | ||
l.WaitN(context.TODO(), tokens) | ||
return | ||
} | ||
|
||
for tokens > 0 { | ||
// Consume limiterBurstSize tokens at a time until we're done. | ||
if tokens > limiterBurstSize { | ||
deviceLimiter.WaitN(context.TODO(), limiterBurstSize) | ||
l.WaitN(context.TODO(), limiterBurstSize) | ||
tokens -= limiterBurstSize | ||
} else { | ||
deviceLimiter.WaitN(context.TODO(), tokens) | ||
l.WaitN(context.TODO(), tokens) | ||
tokens = 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.
This should probably be our
lib/sync