portlist: document, clean up, fix an open fd spike, optimize a bit

I noticed portlist when looking at some profiles and hadn't looked at
the code much before. This is a first pass over it. It allocates a
fair bit. More love remains, but this does a bit:

name       old time/op    new time/op    delta
GetList-8    9.92ms ± 8%    9.64ms ±12%     ~     (p=0.247 n=10+10)

name       old alloc/op   new alloc/op   delta
GetList-8     931kB ± 0%     869kB ± 0%   -6.70%  (p=0.000 n=10+10)

name       old allocs/op  new allocs/op  delta
GetList-8     4.59k ± 0%     3.69k ± 1%  -19.71%  (p=0.000 n=10+10)

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick 2020-03-13 20:53:58 -07:00 committed by Brad Fitzpatrick
parent 6c3820e8c4
commit 120273d7f6
9 changed files with 181 additions and 94 deletions

View File

@ -5,6 +5,7 @@
package ipn package ipn
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"log" "log"
@ -27,6 +28,8 @@
// plane and the local network stack, wiring up NetworkMap updates // plane and the local network stack, wiring up NetworkMap updates
// from the cloud to the local WireGuard engine. // from the cloud to the local WireGuard engine.
type LocalBackend struct { type LocalBackend struct {
ctx context.Context // valid until Close
ctxCancel context.CancelFunc // closes ctx
logf logger.Logf logf logger.Logf
e wgengine.Engine e wgengine.Engine
store StateStore store StateStore
@ -66,12 +69,15 @@ func NewLocalBackend(logf logger.Logf, logid string, store StateStore, e wgengin
// Default filter blocks everything, until Start() is called. // Default filter blocks everything, until Start() is called.
e.SetFilter(filter.NewAllowNone()) e.SetFilter(filter.NewAllowNone())
ctx, cancel := context.WithCancel(context.Background())
portpoll, err := portlist.NewPoller() portpoll, err := portlist.NewPoller()
if err != nil { if err != nil {
logf("skipping portlist: %s\n", err) logf("skipping portlist: %s\n", err)
} }
b := &LocalBackend{ b := &LocalBackend{
ctx: ctx,
ctxCancel: cancel,
logf: logf, logf: logf,
e: e, e: e,
store: store, store: store,
@ -84,7 +90,7 @@ func NewLocalBackend(logf logger.Logf, logid string, store StateStore, e wgengin
e.SetNetInfoCallback(b.SetNetInfo) e.SetNetInfoCallback(b.SetNetInfo)
if b.portpoll != nil { if b.portpoll != nil {
go b.portpoll.Run() go b.portpoll.Run(ctx)
go b.runPoller() go b.runPoller()
} }
@ -92,9 +98,7 @@ func NewLocalBackend(logf logger.Logf, logid string, store StateStore, e wgengin
} }
func (b *LocalBackend) Shutdown() { func (b *LocalBackend) Shutdown() {
if b.portpoll != nil { b.ctxCancel()
b.portpoll.Close()
}
b.c.Shutdown() b.c.Shutdown()
b.e.Close() b.e.Close()
b.e.Wait() b.e.Wait()
@ -313,9 +317,9 @@ func (b *LocalBackend) updateFilter(netMap *controlclient.NetworkMap) {
func (b *LocalBackend) runPoller() { func (b *LocalBackend) runPoller() {
for { for {
ports := <-b.portpoll.C ports, ok := <-b.portpoll.C
if ports == nil { if !ok {
break return
} }
sl := []tailcfg.Service{} sl := []tailcfg.Service{}
for _, p := range ports { for _, p := range ports {

View File

@ -5,7 +5,6 @@
package portlist package portlist
import ( import (
"fmt"
"testing" "testing"
) )
@ -62,28 +61,26 @@ type InOut struct {
` `
func TestParsePortsNetstat(t *testing.T) { func TestParsePortsNetstat(t *testing.T) {
expect := List{ want := List{
Port{"tcp", 22, "", ""}, Port{"tcp", 22, "", ""},
Port{"tcp", 23, "", ""}, Port{"tcp", 23, "", ""},
Port{"tcp", 24, "", ""}, Port{"tcp", 24, "", ""},
Port{"tcp", 32, "", "sshd"}, Port{"tcp", 32, "sshd", ""},
Port{"udp", 53, "", "chrome"}, Port{"udp", 53, "chrome", ""},
Port{"udp", 53, "", "funball"}, Port{"udp", 53, "funball", ""},
Port{"udp", 5050, "", "CDPSvc"}, Port{"udp", 5050, "CDPSvc", ""},
Port{"udp", 5353, "", ""}, Port{"udp", 5353, "", ""},
Port{"udp", 5354, "", ""}, Port{"udp", 5354, "", ""},
Port{"udp", 5453, "", ""}, Port{"udp", 5453, "", ""},
Port{"udp", 5553, "", ""}, Port{"udp", 5553, "", ""},
Port{"udp", 9353, "", "iTunes"}, Port{"udp", 9353, "iTunes", ""},
} }
pl := parsePortsNetstat(netstat_output) pl := parsePortsNetstat(netstat_output)
fmt.Printf("--- expect:\n%v\n", expect)
fmt.Printf("--- got:\n%v\n", pl)
for i := range pl { for i := range pl {
if expect[i] != pl[i] { if pl[i] != want[i] {
t.Fatalf("row#%d\n expect=%v\n got=%v\n", t.Errorf("row#%d\n got: %#v\n\nwant: %#v\n",
i, expect[i], pl[i]) i, pl[i], want[i])
} }
} }
} }

View File

@ -5,40 +5,67 @@
package portlist package portlist
import ( import (
"context"
"time" "time"
) )
// Poller scans the systems for listening ports periodically and sends
// the results to C.
type Poller struct { type Poller struct {
C chan List // new data when it arrives; closed when done // C received the list of ports periodically. It's closed when
// Run completes, after which Err can be checked.
C <-chan List
c chan List
// Err is the error from the final GetList call. It is only
// valid to read once C has been closed. Err is nil if Close
// is called or the context is canceled.
Err error
quitCh chan struct{} // close this to force exit quitCh chan struct{} // close this to force exit
Err error // last returned error code, if any
prev List // most recent data prev List // most recent data
} }
// NewPoller returns a new portlist Poller. It returns an error
// if the portlist couldn't be obtained. Subsequent
func NewPoller() (*Poller, error) { func NewPoller() (*Poller, error) {
p := &Poller{ p := &Poller{
C: make(chan List), c: make(chan List),
quitCh: make(chan struct{}), quitCh: make(chan struct{}),
} }
// Do one initial poll synchronously, so the caller can react p.C = p.c
// to any obvious errors.
p.prev, p.Err = GetList(nil) // Do one initial poll synchronously so we can return an error
return p, p.Err // early.
var err error
p.prev, err = GetList(nil)
if err != nil {
return nil, err
}
return p, nil
} }
func (p *Poller) Close() { func (p *Poller) Close() error {
select {
case <-p.quitCh:
return nil
default:
}
close(p.quitCh) close(p.quitCh)
<-p.C <-p.C
return nil
} }
// Poll periodically. Run this in a goroutine if you want. // Run runs the Poller periodically until either the context
func (p *Poller) Run() error { // is done, or the Close is called.
defer close(p.C) func (p *Poller) Run(ctx context.Context) error {
tick := time.NewTicker(POLL_SECONDS * time.Second) defer close(p.c)
tick := time.NewTicker(pollInterval)
defer tick.Stop() defer tick.Stop()
// Send out the pre-generated initial value // Send out the pre-generated initial value
p.C <- p.prev p.c <- p.prev
for { for {
select { select {
@ -46,12 +73,21 @@ func (p *Poller) Run() error {
pl, err := GetList(p.prev) pl, err := GetList(p.prev)
if err != nil { if err != nil {
p.Err = err p.Err = err
return p.Err return err
} }
if !pl.SameInodes(p.prev) { if pl.SameInodes(p.prev) {
p.prev = pl continue
p.C <- pl
} }
p.prev = pl
select {
case p.c <- pl:
case <-ctx.Done():
return ctx.Err()
case <-p.quitCh:
return nil
}
case <-ctx.Done():
return ctx.Err()
case <-p.quitCh: case <-p.quitCh:
return nil return nil
} }

View File

@ -9,13 +9,16 @@
"strings" "strings"
) )
// Port is a listening port on the machine.
type Port struct { type Port struct {
Proto string Proto string // "tcp" or "udp"
Port uint16 Port uint16 // port number
inode string Process string // optional process name, if found
Process string
inode string // OS-specific; "socket:[165614651]" on Linux
} }
// List is a list of Ports.
type List []Port type List []Port
var protos = []string{"tcp", "udp"} var protos = []string{"tcp", "udp"}
@ -62,12 +65,12 @@ func (a List) SameInodes(b List) bool {
} }
func (pl List) String() string { func (pl List) String() string {
out := []string{} var sb strings.Builder
for _, v := range pl { for _, v := range pl {
out = append(out, fmt.Sprintf("%-3s %5d %-17s %#v", fmt.Fprintf(&sb, "%-3s %5d %-17s %#v\n",
v.Proto, v.Port, v.inode, v.Process)) v.Proto, v.Port, v.inode, v.Process)
} }
return strings.Join(out, "\n") return strings.TrimRight(sb.String(), "\n")
} }
func GetList(prev List) (List, error) { func GetList(prev List) (List, error) {

View File

@ -13,12 +13,13 @@
"log" "log"
"os" "os"
"strings" "strings"
"time"
exec "tailscale.com/tempfork/osexec" exec "tailscale.com/tempfork/osexec"
) )
// We have to run netstat, which is a bit expensive, so don't do it too often. // We have to run netstat, which is a bit expensive, so don't do it too often.
const POLL_SECONDS = 5 const pollInterval = 5 * time.Second
func listPorts() (List, error) { func listPorts() (List, error) {
return listPortsNetstat("-na") return listPortsNetstat("-na")

View File

@ -13,10 +13,13 @@
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"time"
"golang.org/x/sys/unix"
) )
// Reading the sockfiles on Linux is very fast, so we can do it often. // Reading the sockfiles on Linux is very fast, so we can do it often.
const POLL_SECONDS = 1 const pollInterval = 1 * time.Second
// TODO(apenwarr): Include IPv6 ports eventually. // TODO(apenwarr): Include IPv6 ports eventually.
// Right now we don't route IPv6 anyway so it's better to exclude them. // Right now we don't route IPv6 anyway so it's better to exclude them.
@ -82,24 +85,73 @@ func listPorts() (List, error) {
} }
func addProcesses(pl []Port) ([]Port, error) { func addProcesses(pl []Port) ([]Port, error) {
pm := map[string]*Port{} pm := map[string]*Port{} // by Port.inode
for k := range pl { for i := range pl {
pm[pl[k].inode] = &pl[k] pm[pl[i].inode] = &pl[i]
} }
err := foreachPID(func(pid string) error {
fdDir, err := os.Open(fmt.Sprintf("/proc/%s/fd", pid))
if err != nil {
// Can't open fd list for this pid. Maybe
// don't have access. Ignore it.
return nil
}
defer fdDir.Close()
targetBuf := make([]byte, 64) // plenty big for "socket:[165614651]"
for {
fds, err := fdDir.Readdirnames(100)
if err == io.EOF {
return nil
}
if err != nil {
return fmt.Errorf("readdir: %w", err)
}
for _, fd := range fds {
n, err := unix.Readlink(fmt.Sprintf("/proc/%s/fd/%s", pid, fd), targetBuf)
if err != nil {
// Not a symlink or no permission.
// Skip it.
continue
}
// TODO(apenwarr): use /proc/*/cmdline instead of /comm?
// Unsure right now whether users will want the extra detail
// or not.
pe := pm[string(targetBuf[:n])] // m[string([]byte)] avoids alloc
if pe != nil {
comm, err := ioutil.ReadFile(fmt.Sprintf("/proc/%s/comm", pid))
if err != nil {
// Usually shouldn't happen. One possibility is
// the process has gone away, so let's skip it.
continue
}
pe.Process = strings.TrimSpace(string(comm))
}
}
}
})
if err != nil {
return nil, err
}
return pl, nil
}
func foreachPID(fn func(pidStr string) error) error {
pdir, err := os.Open("/proc") pdir, err := os.Open("/proc")
if err != nil { if err != nil {
return nil, fmt.Errorf("/proc: %s", err) return err
} }
defer pdir.Close() defer pdir.Close()
for { for {
pids, err := pdir.Readdirnames(100) pids, err := pdir.Readdirnames(100)
if err == io.EOF { if err == io.EOF {
break return nil
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("/proc: %s", err) return err
} }
for _, pid := range pids { for _, pid := range pids {
@ -109,47 +161,9 @@ func addProcesses(pl []Port) ([]Port, error) {
// /proc has lots of non-pid stuff in it. // /proc has lots of non-pid stuff in it.
continue continue
} }
fddir, err := os.Open(fmt.Sprintf("/proc/%s/fd", pid)) if err := fn(pid); err != nil {
if err != nil { return err
// Can't open fd list for this pid. Maybe
// don't have access. Ignore it.
continue
}
defer fddir.Close()
for {
fds, err := fddir.Readdirnames(100)
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("readdir: %s", err)
}
for _, fd := range fds {
target, err := os.Readlink(fmt.Sprintf("/proc/%s/fd/%s", pid, fd))
if err != nil {
// Not a symlink or no permission.
// Skip it.
continue
}
// TODO(apenwarr): use /proc/*/cmdline instead of /comm?
// Unsure right now whether users will want the extra detail
// or not.
pe := pm[target]
if pe != nil {
comm, err := ioutil.ReadFile(fmt.Sprintf("/proc/%s/comm", pid))
if err != nil {
// Usually shouldn't happen. One possibility is
// the process has gone away, so let's skip it.
continue
}
pe.Process = strings.TrimSpace(string(comm))
}
}
} }
} }
} }
return pl, nil
} }

View File

@ -6,8 +6,10 @@
package portlist package portlist
import "time"
// We have to run netstat, which is a bit expensive, so don't do it too often. // We have to run netstat, which is a bit expensive, so don't do it too often.
const POLL_SECONDS = 5 const pollInterval = 5 * time.Second
func listPorts() (List, error) { func listPorts() (List, error) {
return listPortsNetstat("-na") return listPortsNetstat("-na")

28
portlist/portlist_test.go Normal file
View File

@ -0,0 +1,28 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package portlist
import "testing"
func TestGetList(t *testing.T) {
pl, err := GetList(nil)
if err != nil {
t.Fatal(err)
}
for i, p := range pl {
t.Logf("[%d] %+v", i, p)
}
t.Logf("As String: %v", pl.String())
}
func BenchmarkGetList(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := GetList(nil)
if err != nil {
b.Fatal(err)
}
}
}

View File

@ -4,8 +4,10 @@
package portlist package portlist
import "time"
// Forking on Windows is insanely expensive, so don't do it too often. // Forking on Windows is insanely expensive, so don't do it too often.
const POLL_SECONDS = 5 const pollInterval = 5 * time.Second
func listPorts() (List, error) { func listPorts() (List, error) {
return listPortsNetstat("-na") return listPortsNetstat("-na")