Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion cgroup1/cpuacct.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package cgroup1

import (
"bufio"
"bytes"
"fmt"
"os"
"path/filepath"
Expand All @@ -34,11 +35,13 @@ var clockTicks = getClockTicks()
func NewCpuacct(root string) *cpuacctController {
return &cpuacctController{
root: filepath.Join(root, string(Cpuacct)),
buf: bytes.NewBuffer(make([]byte, 0, 32)),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the idea!

I'm not sure how many users are using single subsystem. If we agree with this change, we should add comment about Stat function with it is not thread-safety as warning.

cc @AkihiroSuda

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be any of the methods that call readUint not just Stat, but yea either a warning or some way to avoid this #275 (comment)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to get try this.
See: #277

}
}

type cpuacctController struct {
root string
buf *bytes.Buffer
}

func (c *cpuacctController) Name() Name {
Expand All @@ -54,7 +57,7 @@ func (c *cpuacctController) Stat(path string, stats *v1.Metrics) error {
if err != nil {
return err
}
total, err := readUint(filepath.Join(c.Path(path), "cpuacct.usage"))
total, err := readUint(filepath.Join(c.Path(path), "cpuacct.usage"), c.buf)
if err != nil {
return err
}
Expand Down
5 changes: 4 additions & 1 deletion cgroup1/hugetlb.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package cgroup1

import (
"bytes"
"os"
"path/filepath"
"strconv"
Expand All @@ -35,12 +36,14 @@ func NewHugetlb(root string) (*hugetlbController, error) {
return &hugetlbController{
root: filepath.Join(root, string(Hugetlb)),
sizes: sizes,
buf: bytes.NewBuffer(make([]byte, 0, 32)),
}, nil
}

type hugetlbController struct {
root string
sizes []string
buf *bytes.Buffer
}

func (h *hugetlbController) Name() Name {
Expand Down Expand Up @@ -99,7 +102,7 @@ func (h *hugetlbController) readSizeStat(path, size string) (*v1.HugetlbStat, er
value: &s.Failcnt,
},
} {
v, err := readUint(filepath.Join(h.Path(path), strings.Join([]string{"hugetlb", size, t.name}, ".")))
v, err := readUint(filepath.Join(h.Path(path), strings.Join([]string{"hugetlb", size, t.name}, ".")), h.buf)
if err != nil {
return nil, err
}
Expand Down
7 changes: 5 additions & 2 deletions cgroup1/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package cgroup1

import (
"bufio"
"bytes"
"fmt"
"io"
"os"
Expand Down Expand Up @@ -158,6 +159,7 @@ func NewMemory(root string, options ...func(*memoryController)) *memoryControlle
mc := &memoryController{
root: filepath.Join(root, string(Memory)),
ignored: map[string]struct{}{},
buf: bytes.NewBuffer(make([]byte, 0, 32)),
}
for _, opt := range options {
opt(mc)
Expand Down Expand Up @@ -189,6 +191,7 @@ func OptionalSwap() func(*memoryController) {
type memoryController struct {
root string
ignored map[string]struct{}
buf *bytes.Buffer
}

func (m *memoryController) Name() Name {
Expand Down Expand Up @@ -220,7 +223,7 @@ func (m *memoryController) Update(path string, resources *specs.LinuxResources)
if g(resources.Memory.Limit) && g(resources.Memory.Swap) {
// if the updated swap value is larger than the current memory limit set the swap changes first
// then set the memory limit as swap must always be larger than the current limit
current, err := readUint(filepath.Join(m.Path(path), "memory.limit_in_bytes"))
current, err := readUint(filepath.Join(m.Path(path), "memory.limit_in_bytes"), m.buf)
if err != nil {
return err
}
Expand Down Expand Up @@ -306,7 +309,7 @@ func (m *memoryController) Stat(path string, stats *v1.Metrics) error {
parts = append(parts, t.module)
}
parts = append(parts, tt.name)
v, err := readUint(filepath.Join(m.Path(path), strings.Join(parts, ".")))
v, err := readUint(filepath.Join(m.Path(path), strings.Join(parts, ".")), m.buf)
if err != nil {
return err
}
Expand Down
5 changes: 4 additions & 1 deletion cgroup1/pids.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package cgroup1

import (
"bytes"
"os"
"path/filepath"
"strconv"
Expand All @@ -29,11 +30,13 @@ import (
func NewPids(root string) *pidsController {
return &pidsController{
root: filepath.Join(root, string(Pids)),
buf: bytes.NewBuffer(make([]byte, 0, 32)),
}
}

type pidsController struct {
root string
buf *bytes.Buffer
}

func (p *pidsController) Name() Name {
Expand Down Expand Up @@ -63,7 +66,7 @@ func (p *pidsController) Update(path string, resources *specs.LinuxResources) er
}

func (p *pidsController) Stat(path string, stats *v1.Metrics) error {
current, err := readUint(filepath.Join(p.Path(path), "pids.current"))
current, err := readUint(filepath.Join(p.Path(path), "pids.current"), p.buf)
if err != nil {
return err
}
Expand Down
1 change: 1 addition & 0 deletions cgroup1/testdata/uint
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
927396272
19 changes: 16 additions & 3 deletions cgroup1/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ package cgroup1

import (
"bufio"
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
Expand Down Expand Up @@ -130,12 +132,23 @@ func hugePageSizes() ([]string, error) {
return pageSizes, nil
}

func readUint(path string) (uint64, error) {
v, err := os.ReadFile(path)
// readUint reads a uint64 from the given file and uses the given buffer as
// scratch space. The buffer is intended to be reused across calls.
func readUint(path string, buf *bytes.Buffer) (uint64, error) {
Copy link
Member

@dcantah dcantah Mar 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this threadsafe? If you called any of the methods that now invoke this in a couple goroutines, there's no guarantee we'll get back the value we're expecting as another read might finish before, you may end up with interleaved data as shown below, or another thread might hit the buf.Reset before another gets to calling buf.String() and you'd end up trying to parse an empty string.

func readUintHelper(path string, expected uint64, buf *bytes.Buffer) error {
	actual, err := readUint(path, buf)
	if err != nil {
		return err
	}
	if expected != actual {
		return fmt.Errorf("expected %d, got %d", expected, actual)
	}
	return nil
}

func TestReadUint(t *testing.T) {
	var (
		buf = bytes.NewBuffer(nil)
		eg  errgroup.Group
	)
	eg.Go(func() error {
		return readUintHelper("testdata/uint100", 100, buf)
	})
	eg.Go(func() error {
		return readUintHelper("testdata/uint200", 200, buf)
	})
	eg.Go(func() error {
		return readUintHelper("testdata/uint300", 300, buf)
	})

	if err := eg.Wait(); err != nil {
		t.Fatal(err)
	}
}

Yields:

dcantah@dcantah ~/go/src/github.com/containerd/cgroups ((HEAD detached at brancz/readuint)) $ go test ./cgroup1/ -v -run TestReadUint
=== RUN   TestReadUint
    utils_test.go:66: expected 100, got 300100
--- FAIL: TestReadUint (0.00s)
FAIL
FAIL    github.com/containerd/cgroups/v3/cgroup1        0.003s
FAIL
dcantah@dcantah ~/go/src/github.com/containerd/cgroups ((HEAD detached at brancz/readuint)) $ go test ./cgroup1/ -v -run TestReadUint
=== RUN   TestReadUint
    utils_test.go:66: expected 100, got 200100
--- FAIL: TestReadUint (0.00s)
FAIL
FAIL    github.com/containerd/cgroups/v3/cgroup1        0.002s
FAIL
dcantah@dcantah ~/go/src/github.com/containerd/cgroups ((HEAD detached at brancz/readuint)) $ go test ./cgroup1/ -v -run TestReadUint
=== RUN   TestReadUint
    utils_test.go:66: strconv.ParseUint: parsing "": invalid syntax
--- FAIL: TestReadUint (0.00s)
FAIL
FAIL    github.com/containerd/cgroups/v3/cgroup1        0.002s
FAIL

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh nice, I forgot the cg1 code has a lock around most of these for the main *cgroup operations. Let me see if there's any that the above might be a concern

Copy link
Member

@dcantah dcantah Mar 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the very least if you created individual controllers (e.g. cgroup1.NewMemory() -> mem.Stat()) you could run into this

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I was briefly contemplating using a sync.Pool, but the way I understood the flow of the code there is no concurrency in the controllers. It’s entirely possible I missed the code path that does, so if that’s the case I’d happily change it to a pool to make it concurrency safe.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the controllers themselves there's not, but the library doesn't dissuade you from doing this yourself. How containerd uses this library is mainly through manipulating the main Cgroup interface from New or Load (https://github.com/containerd/cgroups/blob/main/cgroup1/cgroup.go#L37), and Stat and a bunch of others for this object lock as they collect the data from each controller/subsystem, so containerd's usage may be fine.

The library offers you the ability to Create individual controllers however with New{Controller} functions (NewMemory for example). These don't have any locking around them, so if anyone using this library relied on making an individual controller and then spinning up some goroutines to grab/update data, they may run into this now.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You’re absolutely right. How do you propose to move forward? I would say we can either analyze usages of the library and if there are no current concurrent usages we add a code comment saying they are not concurrency safe, or we go straight to the hammer and use a buffer pool?

I generally prefer to not add synchronization if I don’t absolutely have to, but ultimately your call as a maintainer.

Copy link
Member

@dcantah dcantah Mar 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You wanna benchmark the buffer pool idea? It'd be a shame if this caused us to not be able to freely call these however we want, so anything to keep that alive would be nice. Thanks for the quick follow ups! This is awesome to see btw, very cool stream idea

Copy link

@Kern-- Kern-- Mar 29, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the problem before was that os.ReadFile returns a byte slice which forces an allocation because it escapes the stack.

You can make this much simpler and still thread safe by creating a slice with make([]byte, 64) and f.Read to read the data. Since that slice isn't returned from the function, it stays on the stack.

Copy link
Member

@dcantah dcantah Mar 29, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh fantastic point duh, can confirm it shouldn't escape:

package main

import (
	"log"
	"os"
)

func readFile() error {
	f, err := os.Open("howdy.txt")
	if err != nil {
		return err
	}
	defer f.Close()

	howdy := make([]byte, 64)
	_, err = f.Read(howdy)
	if err != nil {
		return err
	}
	return nil
}

func main() {
	if err := readFile(); err != nil {
		log.Fatal(err)
	}
}
alloc go build -gcflags '-m -l'
# github.com/dcantah/fun/alloc
./main.go:15:15: make([]byte, 64) does not escape
./main.go:25:12: ... argument does not escape

Every so often we must be reminded of why io.Reader's signature is the way it is 😆

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brancz Want to take this for a spin?

buf.Reset()

f, err := os.Open(path)
if err != nil {
return 0, err
}
return parseUint(strings.TrimSpace(string(v)), 10, 64)
defer f.Close()

_, err = io.Copy(buf, f)
if err != nil {
return 0, err
}

return parseUint(strings.TrimSpace(buf.String()), 10, 64)
}

func parseUint(s string, base, bitSize int) (uint64, error) {
Expand Down
34 changes: 34 additions & 0 deletions cgroup1/utils_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
Copyright The containerd Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package cgroup1

import (
"bytes"
"testing"
)

func BenchmarkReaduint64(b *testing.B) {
b.ReportAllocs()

buf := bytes.NewBuffer(nil)
for i := 0; i < b.N; i++ {
_, err := readUint("testdata/uint", buf)
if err != nil {
b.Fatal(err)
}
}
}