Плохой ход: возвращается указатель

Этот пост тоже здесь, где код отформатирован лучше.

Как старый программист на C, я борюсь с этим: для функций кажется совершенно нормальным возвращать указатели на структуры. Но мне кажется, что это плохой Go, и что обычно лучше возвращать значения структуры. Я собираюсь посмотреть, смогу ли я доказать, что возвращение значений структуры просто лучше, а возвращение указателей - плохой Go.

Я собираюсь определить структуру, размер которой можно легко варьировать. Содержимое структуры представляет собой массив: я могу изменить размер структуры, просто изменив размер массива.

const bigStructSize = 10
type bigStruct struct {
	a [bigStructSize]int
}

Затем я создам пару процедур для создания новой версии этой структуры. Один вернет его как указатель, другой - как значение.

func newBigStruct() bigStruct {
	var b bigStruct
	for i := 0; i < bigStructSize; i++ {
		b.a[i] = i
	}
	return b
}
func newBigStructPtr() *bigStruct {
	var b bigStruct
	for i := 0; i < bigStructSize; i++ {
		b.a[i] = i
	}
	return &b
}

Наконец, я напишу пару тестов, чтобы измерить, сколько времени потребуется, чтобы получить и использовать эти структуры. Я собираюсь сделать простой расчет значений в структуре, чтобы компилятор не просто оптимизировал все это.

func BenchmarkStructReturnValue(b *testing.B) {
	b.ReportAllocs()
	t := 0
	for i := 0; i < b.N; i++ {
		v := newBigStruct()
		t += v.a[0]
	}
}
func BenchmarkStructReturnPointer(b *testing.B) {
	b.ReportAllocs()
	t := 0
	for i := 0; i < b.N; i++ {
		v := newBigStructPtr()
		t += v.a[0]
	}
}

Если для bigStructSize установлено значение 10, возврат по значению примерно в два раза быстрее, чем возврат указателя. В случае указателя память должна быть выделена в куче, что займет около 25 нс, затем данные будут установлены (что должно занять примерно одинаковое время в обоих случаях), затем указатель записывается в стек, чтобы вернуть struct вызывающему. В случае значения нет выделения, но вся структура должна быть скопирована в стек, чтобы вернуть ее вызывающей стороне.

При таком размере структуры накладные расходы на копирование данных в стек меньше накладных расходов на выделение памяти.

BenchmarkStructReturnValue-8  	100000000	15.4 ns/op	 0 B/op	0 allocs/op
BenchmarkStructReturnPointer-8	50000000	36.5 ns/op	80 B/op	1 allocs/op

Когда мы изменяем bigStructSize на 100, так что структура теперь содержит 100 int, разрыв в абсолютном выражении увеличивается, хотя процентное увеличение для случая указателя меньше.

BenchmarkStructReturnValue-8  	20000000	105 ns/op	  0 B/op	0 allocs/op
BenchmarkStructReturnPointer-8	10000000	185 ns/op	896 B/op	1 allocs/op

Конечно, если мы попробуем 1000 int в структуре, тогда возврат указателя будет быстрее?

BenchmarkStructReturnValue-8  	2000000	 830 ns/op	   0 B/op	0 allocs/op
BenchmarkStructReturnPointer-8	1000000	1401 ns/op	8192 B/op	1 allocs/op

Нет, все еще намного хуже. Как насчет 10 000?

BenchmarkStructReturnValue-8  	100000	13332 ns/op	    0 B/op	0 allocs/op
BenchmarkStructReturnPointer-8	200000	11032 ns/op	81920 B/op	1 allocs/op

Наконец, с 10 000 int в нашей структуре возврат указателя на структуру происходит быстрее. После некоторого дальнейшего расследования, похоже, что переломный момент для меня на моем ноутбуке - 2700. На данный момент я очень плохо понимаю, почему существует такая большая разница в 1000 единиц. Давайте проанализируем эталонный тест!

go test -bench BenchmarkStructReturnValue -run ^$ -cpuprofile cpu2.prof
go tool pprof  post.test cpu2.prof 
(pprof) top
Showing nodes accounting for 2.25s, 100% of 2.25s total
      flat  flat%   sum%        cum   cum%
     2.09s 92.89% 92.89%      2.23s 99.11%  github.com/philpearl/blog/content/post.newBigStruct
     0.14s  6.22% 99.11%      0.14s  6.22%  runtime.newstack
     0.02s  0.89%   100%      0.02s  0.89%  runtime.nanotime
         0     0%   100%      2.23s 99.11%  github.com/philpearl/blog/content/post.BenchmarkStructReturnValue
         0     0%   100%      0.02s  0.89%  runtime.mstart
         0     0%   100%      0.02s  0.89%  runtime.mstart1
         0     0%   100%      0.02s  0.89%  runtime.sysmon
         0     0%   100%      2.23s 99.11%  testing.(*B).launch
         0     0%   100%      2.23s 99.11%  testing.(*B).runN

В случае значения почти вся работа выполняется в newBigStruct. Все очень просто. Что, если мы профилируем тест указателя?

go test -bench BenchmarkStructReturnPointer -run ^$ -cpuprofile cpu.prof
go tool pprof post.test cpu.prof 
(pprof) top
Showing nodes accounting for 2690ms, 93.08% of 2890ms total
Dropped 28 nodes (cum <= 14.45ms)
Showing top 10 nodes out of 67
      flat  flat%   sum%        cum   cum%
    1110ms 38.41% 38.41%     1110ms 38.41%  runtime.pthread_cond_signal
     790ms 27.34% 65.74%      790ms 27.34%  runtime.pthread_cond_wait
     300ms 10.38% 76.12%      300ms 10.38%  runtime.usleep
     200ms  6.92% 83.04%      200ms  6.92%  runtime.pthread_cond_timedwait_relative_np
      80ms  2.77% 85.81%       80ms  2.77%  runtime.nanotime
      60ms  2.08% 87.89%      140ms  4.84%  runtime.sweepone
      50ms  1.73% 89.62%       50ms  1.73%  runtime.pthread_mutex_lock
      40ms  1.38% 91.00%      150ms  5.19%  github.com/philpearl/blog/content/post.newBigStructPtr
      30ms  1.04% 92.04%       40ms  1.38%  runtime.gcMarkDone
      30ms  1.04% 93.08%       40ms  1.38%  runtime.scanobject

В случае newBigStructPtr картина намного сложнее, и есть гораздо больше функций, которые используют значительный объем ЦП. Только ~ 5% времени тратится на настройку структуры newBigStructPtr. Вместо этого в среде выполнения Go много времени уделяется потокам, блокировкам и сборке мусора. Базовая функция, возвращающая указатель, выполняется быстро, но выделение указателя связано с огромными накладными расходами.

Этот сценарий очень упрощен. Данные создаются, а затем сразу же выбрасываются, поэтому сборщик мусора ляжет большой нагрузкой. Если бы время жизни возвращенных данных было больше, результаты могли бы сильно отличаться. Но, возможно, это признак того, что возвращение указателей на структуры с коротким временем жизни - плохой Go.