Go'da benchmark, _test.go dosyasına yazılan func BenchmarkXxx(b *testing.B) fonksiyonudur ve go test -bench=. ile çalışır. Go 1.24'ten beri önerilen biçim, ölçülecek kodu for b.Loop() { ... } döngüsüne koymaktır; döngüden önceki hazırlık ölçüme girmez. Fuzz testi ise func FuzzXxx(f *testing.F) biçimindedir: f.Add ile tohum girdiler verirsiniz, go test -fuzz=FuzzXxx bu girdileri rastgele değiştirerek kodunuzu bozan bir girdi arar ve bulduğunu testdata/fuzz altına kaydeder.

Bu yazıda iki aracı testing paketinin belgesine, Go Blog'daki testing.B.Loop yazısına ve go.dev'deki fuzzing belgesi ile öğreticisine dayanarak anlatıyoruz. Önce dizgi birleştirmenin iki yolunu ölçüyor, sonra bir ters çevirme fonksiyonundaki iki gerçek hatayı fuzzing ile buluyoruz. Komutların tamamı Go 1.27.3 ile Windows/amd64 üzerinde çalıştırıldı. Benchmark sürelerinin donanıma ve o anki yüke bağlı olduğunu baştan söyleyelim: aşağıdaki sayılar bu makinedeki tek bir çalıştırmadır ve sizde farklı çıkacaktır. Sıradan birim testlerin yazımı Go test yazma rehberi yazısında; serinin tamamı için Go öğrenme rehberi sayfasına bakabilirsiniz.

İlk benchmark: += ile strings.Builder

Ölçeceğimiz paket, bir dizgi dilimini iki farklı yöntemle birleştiriyor. Arti her adımda += ile yeni bir dizgi oluşturuyor, Builder ise strings.Builder kullanıyor:

go
// Package birlestir, dizgi birleştirme yöntemlerini karşılaştırır.
package birlestir

import "strings"

// Arti, parçaları += ile birleştirir.
func Arti(parcalar []string) string {
	s := ""
	for _, p := range parcalar {
		s += p
	}
	return s
}

// Builder, parçaları strings.Builder ile birleştirir.
func Builder(parcalar []string) string {
	var b strings.Builder
	for _, p := range parcalar {
		b.WriteString(p)
	}
	return b.String()
}

Benchmark fonksiyonları test dosyasında, testlerle aynı yerde durur. veri fonksiyonuyla hazırlanan girdi b.Loop çağrısından önce oluşturulduğu için ölçüme dahil edilmez. Üçüncü fonksiyon b.Run ile girdi boyutuna göre alt benchmark'lar üretiyor ve b.ReportAllocs ile bellek ayırma istatistiklerini istiyor:

go
package birlestir

import (
	"fmt"
	"strings"
	"testing"
)

func veri(n int) []string {
	return strings.Split(strings.Repeat("go,", n), ",")
}

func BenchmarkArti(b *testing.B) {
	p := veri(100)
	for b.Loop() {
		Arti(p)
	}
}

func BenchmarkBuilder(b *testing.B) {
	p := veri(100)
	for b.Loop() {
		Builder(p)
	}
}

func BenchmarkBuilderBoyut(b *testing.B) {
	for _, n := range []int{10, 1000} {
		p := veri(n)
		b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
			b.ReportAllocs()
			for b.Loop() {
				Builder(p)
			}
		})
	}
}
bash
$ go test -bench=. -benchmem
goos: windows
goarch: amd64
pkg: example.com/birlestir
cpu: Intel(R) Core(TM) i9-14900KF
BenchmarkArti-32            	  241447	      4728 ns/op	   10736 B/op	      99 allocs/op
BenchmarkBuilder-32         	 2025229	       550.6 ns/op	     504 B/op	       6 allocs/op
BenchmarkBuilderBoyut/n=10-32         	12877677	        96.86 ns/op	      56 B/op	       3 allocs/op
BenchmarkBuilderBoyut/n=1000-32       	  268161	      4427 ns/op	    5368 B/op	      10 allocs/op
PASS
ok  	example.com/birlestir	4.755s

Çıktı nasıl okunur?

Çıktı nasıl okunur? BenchmarkArti-32, 241447, 4728 ns/op, 10736 B/op

testing belgesindeki açıklamaya göre bir satırdaki ilk sayı döngü gövdesinin kaç kez çalıştığını, ns/op ise bir yinelemenin ortalama süresini gösterir. Adın sonundaki -32 eki, ölçümün yapıldığı GOMAXPROCS değeridir; aynı benchmark'ı -cpu 1,4 ile çalıştırdığımızda satırlar BenchmarkBuilder ve BenchmarkBuilder-4 adıyla geldi. -benchmem bayrağı her satıra iki sütun ekler: yineleme başına ayrılan bayt (B/op) ve ayırma sayısı (allocs/op). b.ReportAllocs() aynı bilgiyi yalnızca çağrıldığı benchmark için açar.

SütunAnlamı
BenchmarkArti-32Benchmark adı ve GOMAXPROCS değeri
241447Döngü gövdesinin çalıştırılma sayısı
4728 ns/opBir yinelemenin ortalama süresi (nanosaniye)
10736 B/opYineleme başına ayrılan bellek (-benchmem ya da ReportAllocs)
99 allocs/opYineleme başına bellek ayırma sayısı

Bu çalıştırmada 101 parçalık girdide += yöntemi yaklaşık 99 ayırma yaptı, strings.Builder ise 6. Sonucun yorumu basit: += her adımda öncekinden uzun yeni bir dizgi üretir, Builder ise içindeki tamponu gerektikçe büyütür. Ama tek bir çalıştırmanın süresine bakarak karar vermeyin. testing belgesi, sonuçları istatistiksel olarak karşılaştırmak için golang.org/x/perf/cmd/benchstat aracını önerir; bunun için her benchmark'ı -count ile birkaç kez çalıştırıp çıktıları dosyaya yazmanız gerekir.

Süre yerine sabit yineleme sayısıyla çalıştırmak için -benchtime bayrağında Nx sözdizimi kullanılır; varsayılan süre 1 saniyedir. -count her benchmark'ı istenen sayıda tekrarlar. Aynı benchmark'ın iki çalıştırması arasındaki farka dikkat edin:

bash
$ go test -bench='Builder$' -benchtime=100x -count=2
goos: windows
goarch: amd64
pkg: example.com/birlestir
cpu: Intel(R) Core(TM) i9-14900KF
BenchmarkBuilder-32    	     100	       665.0 ns/op
BenchmarkBuilder-32    	     100	       400.0 ns/op
PASS
ok  	example.com/birlestir	0.026s

Yalnızca 100 yinelemeyle ölçülen sonuçlar arasında belirgin bir sapma var; bu yüzden küçük yineleme sayıları güvenilir karşılaştırma için uygun değildir. -bench değeri de -run gibi eğik çizgiyle bölünen bir düzenli ifadedir: -bench='BuilderBoyut/n=1000' yalnızca büyük girdili alt benchmark'ı çalıştırır. -bench verilmezse hiçbir benchmark çalışmaz; varsayılan go test yalnızca testleri koşturur.

b.Loop neden b.N yerine tercih ediliyor?

Go 1.24'ten önce benchmark'lar for range b.N biçiminde yazılıyordu ve bu biçim hâlâ geçerlidir. Bu stilde testing paketi fonksiyonu farklı b.N değerleriyle defalarca çağırır, ta ki ölçüm güvenilir olacak kadar uzun sürene kadar. Sonuç olarak döngüden önceki hazırlık kodu da birkaç kez çalışır ve maliyetliyse ölçümü bozmaması için b.ResetTimer() çağırmanız gerekir. Aynı ölçümün iki stilde yazılışı şöyledir:

go
// Eski stil: hazırlık her çağrıda tekrarlanır, zamanlayıcı elle sıfırlanır.
func BenchmarkEski(b *testing.B) {
	p := veri(100)
	b.ResetTimer()
	for range b.N {
		Builder(p)
	}
}

// Yeni stil: fonksiyon bir kez çağrılır, zamanlayıcıyı b.Loop yönetir.
func BenchmarkYeni(b *testing.B) {
	p := veri(100)
	for b.Loop() {
		Builder(p)
	}
}

Go 1.24 sürüm notları b.Loop'un iki önemli üstünlüğünü sayar: benchmark fonksiyonu her -count için tam bir kez çalışır, böylece pahalı hazırlık ve temizlik de bir kez yapılır; ayrıca fonksiyon çağrılarının parametreleri ve sonuçları canlı tutulur, derleyici döngü gövdesini tamamen silemez. testing belgesi ayrıntıyı da verir: Loop ilk çağrıldığında zamanlayıcıyı sıfırlar, false döndüğünde durdurur; bu canlı tutma yalnızca süslü parantezlerin içindeki deyimlere uygulanır ve döngü koşulu tam olarak b.Loop() yazılmalıdır. Döngü bittikten sonra b.N toplam yineleme sayısını içerir ve ek metrikler hesaplamak için kullanılabilir.

Go Blog'daki yazı eski stilin en sinsi tuzağını örnekler: sonucu kullanılmayan, satır içine alınabilen küçük bir fonksiyonu b.N döngüsünde ölçtüğünüzde derleyici çağrıyı ölü kod olarak silebilir ve benchmark gerçekte "hiçbir şey yapmamanın" süresini ölçer. Nanosaniyenin altında çıkan bir sonuç bunun açık işaretidir, ama daha karmaşık kodda kısmi silme makul görünen yanlış sonuçlar üretebilir. Go 1.26 sürüm notlarına göre b.Loop artık döngü gövdesinde satır içine almayı da engellemiyor ve ekip bütün benchmark'ların yan etki olmadan b.Loop stiline çevrilebileceğini bekliyor.

Paralel bir ortamda performansı ölçmek için b.RunParallel kullanılır; belge bu tür benchmark'ların -cpu bayrağıyla birlikte çalıştırılmasını önerir. Bir değeri özel bir birimle raporlamak için ise b.ReportMetric vardır. Benchmark sonuçları profil çıkarmanın yerini tutmaz; hangi satırın yavaş olduğunu görmek için -cpuprofile ve -memprofile bayraklarıyla profil dosyası üretilebilir.

Fuzzing nedir?

Fuzzing nedir? string, []byte, int, int8, int16, int32 (rune), int64, uint, uint8 (byte), uint16, uint32, uint64, float32, float64

Go Fuzzing belgesi fuzzing'i, hataları bulmak için girdileri sürekli değiştiren otomatik bir test tekniği olarak tanımlar. Go'nun yerleşik fuzzing desteği Go 1.18 ile geldi ve kapsama yönlendirmelidir: motor, kodun yeni dallarına ulaşan girdileri "ilginç" sayıp saklar ve onları değiştirerek ilerler. Birim testinde beklenen çıktıyı siz yazarsınız; fuzz testinde ise girdiyi bilmediğiniz için çıktıyı tahmin edemezsiniz, bu yüzden her girdi için doğru olması gereken özellikleri denetlersiniz: "iki kez ters çevrilen metin aslına döner" gibi.

Belgedeki kurallar kısaca şöyle: fuzz testi FuzzXxx adında, yalnızca *testing.F alan ve değer döndürmeyen bir fonksiyondur ve _test.go dosyasında olmalıdır. İçinde tam olarak bir tane f.Fuzz çağrısı bulunur; bu çağrıya verilen fonksiyona fuzz hedefi denir ve ilk parametresi *testing.T, sonrakiler fuzzing argümanlarıdır. f.Add ile eklenen tohum girdilerin tipleri bu argümanlarla aynı sırada ve aynı tipte olmalıdır.

Desteklenen argüman tipleri
string, []byte
int, int8, int16, int32 (rune), int64
uint, uint8 (byte), uint16, uint32, uint64
float32, float64
bool

Uygulamalı örnek: ters çevirme fonksiyonundaki iki hata

go.dev'deki fuzzing öğreticisinin izlediği yolu kendi paketimizde tekrarlayalım. İlk sürüm metni bayt bayt ters çeviriyor:

go
// Package ters, dizgileri ters çevirir.
package ters

// Ters, s dizgisini bayt bayt ters çevirir.
func Ters(s string) string {
	b := []byte(s)
	for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
		b[i], b[j] = b[j], b[i]
	}
	return string(b)
}

Fuzz testi iki özelliği denetliyor: iki kez ters çevrilen metin aslına eşit olmalı ve geçerli UTF-8 bir girdinin tersi de geçerli UTF-8 olmalı. Üç tohum girdi f.Add ile veriliyor:

go
package ters

import (
	"testing"
	"unicode/utf8"
)

func FuzzTers(f *testing.F) {
	for _, tohum := range []string{"Merhaba", " ", "!12345"} {
		f.Add(tohum)
	}
	f.Fuzz(func(t *testing.T, orijinal string) {
		ters := Ters(orijinal)
		ikiKez := Ters(ters)
		if orijinal != ikiKez {
			t.Errorf("önce: %q, sonra: %q", orijinal, ikiKez)
		}
		if utf8.ValidString(orijinal) && !utf8.ValidString(ters) {
			t.Errorf("Ters geçersiz UTF-8 üretti: %q", ters)
		}
	})
}

-fuzz verilmeden çalıştırılan go test, fuzz testini sıradan bir test gibi koşturur: her tohum girdi bir alt test olur. Üç tohum da geçti, çünkü hepsi ASCII:

bash
$ go test -v
=== RUN   FuzzTers
=== RUN   FuzzTers/seed#0
=== RUN   FuzzTers/seed#1
=== RUN   FuzzTers/seed#2
--- PASS: FuzzTers (0.00s)
    --- PASS: FuzzTers/seed#0 (0.00s)
    --- PASS: FuzzTers/seed#1 (0.00s)
    --- PASS: FuzzTers/seed#2 (0.00s)
PASS
ok  	example.com/ters	0.025s

Şimdi fuzzing'i açalım. -fuzz değeri tek bir paketteki tek bir fuzz testiyle eşleşmelidir; -fuzztime verilmezse motor bir hata bulana ya da siz durdurana kadar çalışır. Aşağıdaki çıktıdaki işçi sayısı, çalıştırma hızı ve dosya adı her çalıştırmada değişir:

bash
$ go test -fuzz=FuzzTers -fuzztime=30s
fuzz: elapsed: 0s, gathering baseline coverage: 0/3 completed
fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, now fuzzing with 32 workers
fuzz: elapsed: 0s, execs: 1792 (4188/sec), new interesting: 10 (total: 13)
--- FAIL: FuzzTers (0.45s)
    --- FAIL: FuzzTers (0.00s)
        ters_test.go:19: Ters geçersiz UTF-8 üretti: "\x87\xd5"
    
    Failing input written to testdata/fuzz/FuzzTers/d44c603f1ba88a81
    To re-run:
    go test -run=FuzzTers/d44c603f1ba88a81
FAIL
exit status 1
FAIL	example.com/ters	0.498s

Motor yarım saniyede bir sorun buldu. Kaydedilen dosya, belgedeki korpus biçimindedir: ilk satır kodlama sürümünü, sonraki satırlar Go sözdizimiyle yazılmış argüman değerlerini taşır:

text
go test fuzz v1
string("Շ")

Ermeni alfabesinden bu tek karakter UTF-8'de iki bayttır; baytları yer değiştirince geçersiz bir dizi ortaya çıkıyor. Bu dosya artık tohum korpusun parçasıdır: -fuzz olmadan çalıştırılan go test de onu dener ve hata giderilene kadar başarısız olur. Belgenin önerdiği akış da budur: hatayı düzeltin, go test ile doğrulayın ve testdata dosyasını gerileme testi olarak depoya ekleyin.

Fonksiyonu rune bazında ters çevirecek şekilde düzelttik (b := []byte(s) yerine r := []rune(s)). Tohumlar ve kaydedilen girdi geçti, ama fuzzing ikinci bir hata buldu:

text
--- FAIL: FuzzTers (0.39s)
    --- FAIL: FuzzTers (0.00s)
        ters_test.go:16: önce: "\x8f", sonra: "�"

Girdi tek bir geçersiz bayttı. []rune dönüşümü geçersiz baytları Unicode yerine koyma karakterine (U+FFFD) çevirdiği için iki kez ters çevrilen metin aslına dönmüyor. Öğreticideki çözümü uyguladık: fonksiyon geçerli UTF-8 olmayan girdiyi hata ile reddediyor, fuzz hedefi de bu girdileri t.Skip() ile atlıyor. testing belgesi, geçersiz ama başarısızlık sayılmaması gereken girdiler için t.Skip kullanımını tam olarak bu amaçla gösterir.

go
// Package ters, dizgileri ters çevirir.
package ters

import (
	"errors"
	"unicode/utf8"
)

// Ters, s dizgisini rune rune ters çevirir; s geçerli UTF-8 değilse hata döner.
func Ters(s string) (string, error) {
	if !utf8.ValidString(s) {
		return s, errors.New("girdi geçerli UTF-8 değil")
	}
	r := []rune(s)
	for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
		r[i], r[j] = r[j], r[i]
	}
	return string(r), nil
}
go
package ters

import (
	"testing"
	"unicode/utf8"
)

func FuzzTers(f *testing.F) {
	for _, tohum := range []string{"Merhaba", " ", "!12345"} {
		f.Add(tohum)
	}
	f.Fuzz(func(t *testing.T, orijinal string) {
		ters, err := Ters(orijinal)
		if err != nil {
			t.Skip()
		}
		ikiKez, err := Ters(ters)
		if err != nil {
			t.Fatalf("ikinci çevirme hata verdi: %v", err)
		}
		if orijinal != ikiKez {
			t.Errorf("önce: %q, sonra: %q", orijinal, ikiKez)
		}
		if !utf8.ValidString(ters) {
			t.Errorf("Ters geçersiz UTF-8 üretti: %q", ters)
		}
	})
}
bash
$ go test -v
...
=== RUN   FuzzTers/30c8f231c7efb4d1
    ters_test.go:15: 
=== RUN   FuzzTers/d44c603f1ba88a81
--- PASS: FuzzTers (0.00s)
    --- PASS: FuzzTers/seed#0 (0.00s)
    --- PASS: FuzzTers/seed#1 (0.00s)
    --- PASS: FuzzTers/seed#2 (0.00s)
    --- SKIP: FuzzTers/30c8f231c7efb4d1 (0.00s)
    --- PASS: FuzzTers/d44c603f1ba88a81 (0.00s)
PASS
ok  	example.com/ters	0.023s

Kaydedilen iki girdi de artık alt test olarak çalışıyor: biri geçiyor, geçersiz UTF-8 olanı atlanıyor. 10 saniyelik yeni bir fuzzing turu yaklaşık 770 bin girdi denedi ve hata bulamadan PASS ile bitti.

Fuzzing ayarları ve dikkat edilecekler

Fuzzing ayarları ve dikkat edilecekler: -fuzztime, -fuzzminimizetime, -parallel, Platform
  • -fuzztime: Fuzzing'in ne kadar süreceği; 30s gibi bir süre ya da 1000x gibi bir sayı. Varsayılan sınırsızdır.
  • -fuzzminimizetime: Hata bulunduğunda girdiyi küçültme denemesinin süresi; varsayılan 60 saniye, 0 küçültmeyi kapatır.
  • -parallel: Aynı anda çalışan fuzzing süreci sayısı; varsayılan GOMAXPROCS. Fuzzing sırasında -cpu etkisizdir.
  • Platform: Belgeye göre kapsama araçlandırması şu an AMD64 ve ARM64 mimarilerinde var; korpusun anlamlı büyümesi için fuzzing bu mimarilerde çalıştırılmalı.
  • Zaman aşımı: Fuzz hedefinin tek bir çalıştırması şu an 1 saniyeyi aşarsa başarısızlık sayılır; hedefi hızlı tutun.
  • Durum tutmayın: Hedef birden çok işçide paralel ve belirsiz sırayla çağrılır; global duruma bağlı olmamalı ve bir çağrının durumu sonrakine taşınmamalıdır.

Motorun zamanla biriktirdiği "ilginç" girdiler (üretilmiş korpus) paket dizinine değil $GOCACHE/fuzz altına yazılır; yalnızca hataya yol açan girdiler testdata/fuzz/<Ad> altına kaydedilir. Belirli bir korpus girdisini hata ayıklamak için go test -run=FuzzTers/d44c603f1ba88a81 gibi tam adla çalıştırabilirsiniz. Ağ, veritabanı ya da dosya sistemi gibi yavaş ve belirsiz yan etkileri olan kodu doğrudan fuzz etmek yerine, girdiyi ayrıştıran saf fonksiyonları hedeflemek daha verimlidir; JSON çözümleme, strconv dönüşümleri ya da kendi biçim ayrıştırıcılarınız iyi adaylardır.

Sık yapılan hatalar

  • Hazırlığı ölçüme katmak: b.N stilinde pahalı hazırlıktan sonra b.ResetTimer() unutulursa sonuç şişer. b.Loop bunu kendisi yapar.
  • Sonucu kullanılmayan çağrıyı b.N döngüsünde ölçmek: Derleyici çağrıyı silebilir. Nanosaniyenin altında sonuçlar şüphelidir.
  • Tek çalıştırmaya güvenmek: Süreler gürültülüdür. -count ile tekrarlayın ve karşılaştırmayı istatistiksel yapın.
  • Benchmark'ı yük altındaki makinede çalıştırmak: Arka plandaki işler sonuçları bozar; karşılaştırdığınız ölçümleri aynı koşullarda alın.
  • Fuzz hedefinde sabit beklenen değer aramak: Girdi rastgele olduğu için beklenen çıktıyı değil, her girdide doğru olması gereken özelliği denetleyin.
  • testdata/fuzz dosyalarını silmek: Bu dosyalar bulunan hataların gerileme testleridir; depoda tutulmalıdır.

Sık Sorulan Sorular

go test neden benchmark'larımı çalıştırmıyor?

Benchmark'lar varsayılan olarak çalışmaz; -bench bayrağına bir düzenli ifade vermeniz gerekir. Hepsi için -bench=. yazın. Testleri de atlamak istiyorsanız eşleşmeyecek bir -run değeri verebilirsiniz, ör. -run=^$.

b.Loop hangi Go sürümünden itibaren var?

testing.B.Loop Go 1.24 ile geldi. Go 1.26 ile döngü gövdesinde satır içine almayı engelleme davranışı kaldırıldı. Daha eski bir Go sürümünü hedefleyen modüllerde for range b.N stili kullanılır; go.mod dosyasındaki go satırı 1.24'ten düşükse stdversion vet denetimi b.Loop kullanımını raporlar; go 1.23 yazan bir modülde aldığımız mesaj testing.(*B).Loop requires go1.24 or later (module is go1.23) oldu ve go test benchmark'ı çalıştırmadı.

Fuzz testi CI'da nasıl çalıştırılır?

-fuzz verilmeyen her go test çalıştırması tohum korpusu ve testdata/fuzz girdilerini dener, yani gerileme koruması CI'da kendiliğinden çalışır. Fuzzing'in kendisi için -fuzztime ile süre sınırı koymanız gerekir; aksi hâlde komut bitmez.

Fuzzing hangi hatalara karşı işe yarar?

Go belgesine göre fuzzing, insanların gözden kaçırdığı uç durumlara ulaşabildiği için özellikle güvenlik açıklarını bulmakta değerlidir. Bir panik, t.Fail çağrısı, os.Exit ya da yığın taşması gibi kurtarılamaz bir hata ve zaman aşımı başarısızlık sayılır.

Marka adları sahiplerine aittir. Bu yazı bağımsız bir teknik rehberdir; adı geçen kuruluşlarla bayilik veya ortaklık ilişkisi bulunduğu anlamına gelmez.

İlgili içerikler: Go öğrenme rehberi · Go test yazma · Go string, rune ve UTF-8 · gofmt, go vet ve go doc · slices ve sort paketleri · Go veri yarışı ve sync/atomic