Синхронізація в Go: використання спільних даних
Привіт, мене звати Ярослав. Працюю в компанії Evrius , три роки розробляю на Go, а раніше писав на PHP.
Помітив, що коли на співбесіді з Go питають про синхронізацію, то переважно запитання звучить: «Як розпаралелити задачу?». Про це я писав раніше . Але на співбесіді питають про одне, а в проєкті — інше, там значно більше випадків, коли дані читаються з багатьох горутин, а оновлюють в одній. Тоді краще використовувати оптимальні структури sync.RWMutex та atomic.Value. Про це й буде стаття. Тут ви знайдете приклади коду, помилок, тести, бенчмарки.
Матеріал буде цікавий спеціалістам, які збираються перекваліфікуватись на Go або вже мають досвід з цієї мовою та хочуть краще структурувати свої знання.
Власне, я згадав про PHP, бо маю багато знайомих PHP-розробників і інколи відповідаю на запитання «Як перекваліфікуватись з PHP на Go». Також інколи чую, як PHP-розробникам зі знанням Go, рекрутери пропонують розглянути вакансії Golang Team Lead . Якщо вам буде цікаво почитати про те, як перекваліфікуватись з PHP на Go, дайте знати про це в коментарях чи напишіть мені в LinkedIn, щоб я розумів актуальність питання і мав мотивацію взятись за статтю з прикладами коду та порівнянням.
Безпечне читання даних без синхронізації
Коли в коді просто читаємо завчасно завантажені дані без оновлень з багатьох горутин, то синхронізація зайва. Штучний приклад, коли багато горутин читають одні дані:
package main
import (
 "fmt"
)
func main() {
 var values = map[byte]int{
  'A': 1,
  'B': 2,
  'C': 3,
 }
 var keys = []byte{'A', 'B', 'C'}
 // panic: too many concurrent operations on a single file or socket (max 1048575)
 for try := 1048575; try > 0; try-- {
  go func() {
   for i, key := range keys {
    // safe goroutine read data without any sync
    var value = values[key]
    fmt.Printf("index = %d, key = %c, value = %d
", i, key, value)
   }
  }()
 }
}
Я запустив цей приклад з параметром race (для перевірки на data race): go run main.go -race.
Якщо ж у першій горутині будемо читати з мапи, а в другій — писати в мапу, то отримаємо помилку fatal error: concurrent map read and map write . Про цю помилку також написано в офіційному блозі Go maps in action :
Maps are not safe for concurrent use : it’s not defined what happens when you read and write to them simultaneously.Розглянемо її детальніше.
Fatal error: concurrent map read and map write
Якщо взяти попередній приклад і додати одну горутину, яка буде писати в мапу, то отримаємо помилку, навіть якщо будемо оновлювати вже наявні ключі.
// BAD CODE WITH ERROR EXAMPLE
package main
import (
 "fmt"
)
func main() {
 var values = map[byte]int{
  'A': 1,
  'B': 2,
  'C': 3,
 }
 var keys = []byte{'A', 'B', 'C'}
 go func() {
  for {
   for _, key := range keys {
    // UNSAFE, fatal error: concurrent map read and map write
    values[key] = values[key] + 1
   }
  }
 }()
 // panic: too many concurrent operations on a single file or socket (max 1048575)
 for try := 1048575; try > 0; try-- {
  go func() {
   for i, key := range keys {
    // UNSAFE, fatal error: concurrent map read and map write
    var value = values[key]
    fmt.Printf("index = %d, key = %c, value = %d
", i, key, value)
   }
  }()
 }
}
go run main.go -race
Після запуску програма виведе в термінал очікувані повідомлення ~ index = 0, key = A, value = 850 N разів, а потім завершиться помилкою:
fatal error: concurrent map read and map write
Повна заміна мапи без синхронізації
Знову досліджуємо попередній приклад і замість оновлення мапи будемо робити її повну заміну. Перевіримо, чи це безпечно.
// BAD CODE WITH UNSAFE EXAMPLE
package main
import (
 "fmt"
)
func main() {
 var values = map[byte]int{
  'A': 1,
  'B': 2,
  'C': 3,
 }
 var keys = []byte{'A', 'B', 'C'}
 go func() {
  for {
   var replaceValues = make(map[byte]int, len(values))
   for _, key := range keys {
    replaceValues[key] = values[key] + 1
   }
   // UNSAFE replace on some GOARCH, for example arm(32)
   values = replaceValues
  }
 }()
 // panic: too many concurrent operations on a single file or socket (max 1048575)
 for try := 1048575; try > 0; try-- {
  go func() {
   for i, key := range keys {
    // UNSAFE read on some GOARCH, for example arm(32)
    var value = values[key]
    fmt.Printf("index = %d, key = %c, value = %d
", i, key, value)
   }
  }()
 }
}
go run main.go -race
Після запуску жодних помилок у терміналі, отже, повна заміна мапи виконалась успішно на моєму комп’ютері.
go env
GOARCH="amd64" GOHOSTARCH="amd64"
На архітектурі amd64 — повна заміна мапи (вказівник розміром 8 байтів, або 64 біти) є атомарною операцією (безпечною), але на інших архітектурах, таких як 32-бітна arm , будуть помилки. Тому треба писати код, який буде безпечний і на інших архітектурах. Для цього потрібно правильно використовувати синхронізації: sync.Mutex, sync.RWMutex та atomic.Value, які гарантують потокобезпечність.
Універсальний sync.Mutex
Завдання sync.Mutex — надати ексклюзивний доступ до даних за допомогою двох методів Lock() та Unlock() . Візьмемо попередні приклади і зробимо їх потокобезпечними, використовуючи sync.Mutex. Приклад, в якому була помилка fatal error: concurrent map read and map write з sync.Mutex, буде саме таким:
package main
import (
 "fmt"
 "sync"
)
func main() {
 var values = map[byte]int{
  'A': 1,
  'B': 2,
  'C': 3,
 }
 var keys = []byte{'A', 'B', 'C'}
 var mu = new(sync.Mutex)
 go func() {
  for {
   for _, key := range keys {
    mu.Lock()
    values[key] = values[key] + 1
    mu.Unlock()
   }
  }
 }()
 // panic: too many concurrent operations on a single file or socket (max 1048575)
 for try := 1048575; try > 0; try-- {
  go func() {
   for i, key := range keys {
    mu.Lock()
    var value = values[key]
    mu.Unlock()
    fmt.Printf("index = %d, key = %c, value = %d
", i, key, value)
   }
  }()
 }
}
go run main.go -race
Бачимо успішне завершення.
Але правильніше буде винести дані і мютекс в одну структуру, яка буде відповідати за доступ до даних. Об’єднати в одну структуру — це стандартне рішення, бо більше зрозуміло, які маніпуляції з даними відбуваються.
package main
import (
 "fmt"
 "sync"
)
type SyncCounter struct {
 data map[byte]int
 mu   sync.Mutex
}
func NewSyncCounter(data map[byte]int) *SyncCounter {
 return &SyncCounter{
  data: data,
 }
}
func (c *SyncCounter) Increment(key byte) {
 c.mu.Lock()
 c.data[key] += 1
 c.mu.Unlock()
}
func (c *SyncCounter) Get(key byte) int {
 c.mu.Lock()
 var value = c.data[key]
 c.mu.Unlock()
 return value
}
func main() {
 var values = NewSyncCounter(map[byte]int{
  'A': 1,
  'B': 2,
  'C': 3,
 })
 var keys = []byte{'A', 'B', 'C'}
 go func() {
  for {
   for _, key := range keys {
    values.Increment(key)
   }
  }
 }()
 // panic: too many concurrent operations on a single file or socket (max 1048575)
 for try := 1048575; try > 0; try-- {
  go func() {
   for i, key := range keys {
    var value = values.Get(key)
    fmt.Printf("index = %d, key = %c, value = %d
", i, key, value)
   }
  }()
 }
}
На початку знайомства з Go універсального sync.Mutex та каналів вистачить для написання коду, але якщо захочете замінити на sync.RWMutex чи atomic.Value, то краще пишіть тести і запускайте з параметром race , щоб перевірити наявність помилок і можливе погіршення швидкодії після оптимізації.
Повна заміна даних з sync.Mutex, sync.RWMutex та atomic.Value
Далі зосередимось тільки на структурах з даними та потокобезпечним доступом. Коли повністю замінюємо дані, достатньо обгорнути мютексом тільки заміну і отримання вказівника:
import "sync"
type CityOnlineMutexMap struct {
 data map[string]uint32
 mu   sync.Mutex
}
func NewCityOnlineMutexMap(data map[string]uint32) *CityOnlineMutexMap {
 return &CityOnlineMutexMap{
  data: data,
 }
}
func (c *CityOnlineMutexMap) Update(data map[string]uint32) {
 c.mu.Lock()
 c.data = data
 c.mu.Unlock()
}
func (c *CityOnlineMutexMap) Get(cityName string) uint32 {
 c.mu.Lock()
 var data = c.data
 c.mu.Unlock()
 return data[cityName]
}
У цьому прикладі пошук в мапі за ключем я виніс за мютекс, бо це безпечно. Коли дані частіше читаються, ніж пишуться, то можемо використати більш оптимальний для таких дій sync.RWMutex:
import "sync"
type CityOnlineRWMutexMap struct {
 data map[string]uint32
 mu   sync.RWMutex
}
func NewCityOnlineRWMutexMap(data map[string]uint32) *CityOnlineRWMutexMap {
 return &CityOnlineRWMutexMap{
  data: data,
 }
}
func (c *CityOnlineRWMutexMap) Update(data map[string]uint32) {
 c.mu.Lock()
 c.data = data
 c.mu.Unlock()
}
func (c *CityOnlineRWMutexMap) Get(cityName string) uint32 {
 c.mu.RLock()
 var data = c.data
 c.mu.RUnlock()
 return data[cityName]
}
У цьому прикладі заміна буде відбуватись довше, бо під капотом RWMutex.Lock() викликається звичайний Mutex.Lock() та додаткові перевірки. Але читання через RWMutex.RLock швидше. Тести будуть далі.
Яка різниця між Mutex та RWMutex — одне зі стандартних питань на співбесіді. Якщо у вас є налаштований локально Go і IDE, то можете зайти в реалізацію RWMutex та почитати коментарі, які пояснюють, як працює RWMutex:
// RLock // A writer is pending, wait for it. // Lock // Announce to readers there is a pending writer. // Wait for active readers.А найефективніший варіант повної заміни даних через atomic.Value:
import (
 "sync/atomic"
)
type CityOnlineAtomicMap struct {
 data atomic.Value
}
func NewCityOnlineAtomicMap(data map[string]uint32) *CityOnlineAtomicMap {
 var result = new(CityOnlineAtomicMap)
 result.Update(data)
 return result
}
func (c *CityOnlineAtomicMap) Update(data map[string]uint32) {
 c.data.Store(data)
}
func (c *CityOnlineAtomicMap) Get(cityName string) uint32 {
 var data = c.data.Load().(map[string]uint32)
 return data[cityName]
}
А тепер напишемо тест, щоб порівняти, яка структура найкраща для повної заміни даних:
package main
import (
 "sync"
 "testing"
 "time"
)
type cityOnlineMap interface {
 Update(data map[string]uint32)
 Get(cityName string) uint32
}
func BenchmarkCityOnlineMutexMap(b *testing.B) {
 benchmarkCityOnlineMap(b, NewCityOnlineMutexMap(getCityOnlineMap()))
}
func BenchmarkCityOnlineRWMutexMap(b *testing.B) {
 benchmarkCityOnlineMap(b, NewCityOnlineRWMutexMap(getCityOnlineMap()))
}
func BenchmarkCityOnlineAtomicMap(b *testing.B) {
 benchmarkCityOnlineMap(b, NewCityOnlineAtomicMap(getCityOnlineMap()))
}
func benchmarkCityOnlineMap(b *testing.B, data cityOnlineMap) {
 b.Helper()
 var once = new(sync.Once)
 var cities = []string{"kyiv", "kharkiv", "lviv", "dnipro", "odessa"}
 b.ResetTimer()
 b.RunParallel(func(pb *testing.PB) {
  var isWriter = false
  once.Do(func() {
   isWriter = true
  })
  if isWriter {
   for pb.Next() {
    data.Update(getCityOnlineMap())
    // read much more often than it is written
    time.Sleep(time.Microsecond)
   }
  } else {
   for pb.Next() {
    for _, cityName := range cities {
     _ = data.Get(cityName)
    }
   }
  }
 })
}
func getCityOnlineMap() map[string]uint32 {
 var now = uint32(time.Now().Unix())
 return map[string]uint32{
  "kyiv":    now,
  "kharkiv": now,
  "lviv":    now,
  "dnipro":  now,
  "odessa":  now,
 }
}
go test ./... -v -bench=. -benchmem
| Назва тесту | Середній час ітерації | Виділення пам’яті | 
| BenchmarkCityOnlineMutexMap | 410 ns/op | 4 B/op 0 allocs/op | 
| BenchmarkCityOnlineRWMutexMap | 241 ns/op | 0 B/op 0 allocs/op | 
| BenchmarkCityOnlineAtomicMap | 10.4 ns/op | 0 B/op 0 allocs/op | 
Якщо в задачі повна заміна даних, то краще використовувати atomic.Value як найефективнішу структуру (для цілочисельних даних є atomic.StoreUint64, atomic.StoreUint32, atomic.StoreInt64 та atomic.StoreInt32).
Екзотичні варіанти синхронізації через канали
Перший екзотичний варіант, який бачив у реальному проєкті:
// BAD CODE EXAMPLE, DON'T COPY-PASTE
type CityOnlineChanMutexMap struct {
 data      map[string]uint32
 chanMutex chan struct{}
}
func NewCityOnlineChanMutexMap(data map[string]uint32) *CityOnlineChanMutexMap {
 return &CityOnlineChanMutexMap{
  data:      data,
  chanMutex: make(chan struct{}, 1),
 }
}
func (c *CityOnlineChanMutexMap) Update(data map[string]uint32) {
 c.chanMutex <- struct{}{}
 c.data = data
 _ = <-c.chanMutex
}
func (c *CityOnlineChanMutexMap) Get(cityName string) uint32 {
 c.chanMutex <- struct{}{}
 var result = c.data[cityName]
 _ = <-c.chanMutex
 return result
}
Під капотом каналів мютекси, і варіант вище поки найповільніший. У проєкті переписав його на sync.Mutex.
Другий екзотичний варіант помітив на просторах інтернету:
// BAD CODE EXAMPLE, DON'T COPY-PASTE
type cityOnlineRequest struct {
 cityName string
 online   chan uint32
}
type CityOnlineChanReactorMap struct {
 data        map[string]uint32
 requestChan chan cityOnlineRequest
 dataChan    chan map[string]uint32
}
func NewCityOnlineChanReactorMap(data map[string]uint32) *CityOnlineChanReactorMap {
 var result = &CityOnlineChanReactorMap{
  data:        data,
  requestChan: make(chan cityOnlineRequest),
  dataChan:    make(chan map[string]uint32),
 }
 go result.run()
 return result
}
func (c *CityOnlineChanReactorMap) Update(data map[string]uint32) {
 c.dataChan <- data
}
func (c *CityOnlineChanReactorMap) Get(cityName string) uint32 {
 var request = cityOnlineRequest{
  cityName: cityName,
  online:   make(chan uint32),
 }
 c.requestChan <- request
 return <-request.online
}
func (c *CityOnlineChanReactorMap) run() {
 for {
  select {
  case request := <-c.requestChan:
   request.online <- c.data[request.cityName]
  case data := <-c.dataChan:
   c.data = data
  }
 }
}
Цей варіант ще повільніший.
| Назва тесту | Середній час ітерації | Виділення пам’яті | 
| BenchmarkCityOnlineMutexMap | 410 ns/op | 4 B/op 0 allocs/op | 
| BenchmarkCityOnlineRWMutexMap | 241 ns/op | 0 B/op 0 allocs/op | 
| BenchmarkCityOnlineAtomicMap | 10.4 ns/op | 0 B/op 0 allocs/op | 
| BenchmarkCityOnlineChanMutexMap | 2037 ns/op | 60 B/op 0 allocs/op | 
| BenchmarkCityOnlineChanReactorMap | 3740 ns/op | 467 B/op 4 allocs/op | 
Якщо на вашому проєкті є щось схоже, можете відправити мені анонімно — і я додам в коментарях.
Повернення гетера
У попередніх прикладах розглядали взаємодію з даними всередині структури. Але якщо нам знадобиться отримати значення одразу для багатьох ключів, то робити синхронізацію на кожен ключ буде повільно. Якщо ж повернемо всю мапу, буде складніше розуміти, що далі відбувається з даними. Тож повернемо інтерфейс CityOnlineGetter :
import (
 "sync/atomic"
)
type CityOnlineGetter interface {
 Get(cityName string) uint32
}
type CityOnlineMap struct {
 data map[string]uint32
}
func (c *CityOnlineMap) Get(cityName string) uint32 {
 return c.data[cityName]
}
type CityOnlineAtomicMap struct {
 data atomic.Value
}
func NewCityOnlineAtomicMap(data map[string]uint32) *CityOnlineAtomicMap {
 var result = new(CityOnlineAtomicMap)
 result.Update(data)
 return result
}
func (c *CityOnlineAtomicMap) Update(data map[string]uint32) {
 c.data.Store(data)
}
func (c *CityOnlineAtomicMap) Get(cityName string) uint32 {
 var data = c.data.Load().(map[string]uint32)
 return data[cityName]
}
// BAD CODE
//func (c *CityOnlineAtomicMap) Load() map[string]uint32 {
// var data = c.data.Load().(map[string]uint32)
//
// return data
//}
func (c *CityOnlineAtomicMap) Load() CityOnlineGetter {
 var data = c.data.Load().(map[string]uint32)
 return &CityOnlineMap{
  data: data,
 }
}
Помилки, які можуть трапитися під час оновлення даних
Цю помилку, коли мютекс довго блокує виконання, бачив у реальних проєктах. Правильні приклади без помилок вже наведені у цій статті вище.
// BAD CODE EXAMPLE, DON'T COPY-PASTE
import (
 "sync"
 "time"
)
type CityOnline struct {
 CityName string
 Online   uint32
}
type CityOnlineTooLongUpdateMap struct {
 data map[string]uint32
 mu   sync.RWMutex
}
func NewCityOnlineTooLongUpdateMap(data map[string]uint32) *CityOnlineTooLongUpdateMap {
 return &CityOnlineTooLongUpdateMap{
  data: data,
 }
}
func (c *CityOnlineTooLongUpdateMap) UpdateBySlice(items []CityOnline) {
 c.mu.Lock()
 defer c.mu.Unlock()
 // other logic
 // for example API call, emulate by time.Sleep
 time.Sleep(time.Second)
 c.data = make(map[string]uint32, len(items))
 for _, item := range items {
  c.data[item.CityName] = item.Online
 }
}
func (c *CityOnlineTooLongUpdateMap) Get(cityName string) uint32 {
 c.mu.RLock()
 defer c.mu.RUnlock()
 return c.data[cityName]
}
У такому прикладі RLock буде чекати defer c.mu.Unlock() , відповідно читання буде заблоковано на секунду. Те саме стосується звичайного Mutex-а .
atomic.Value та збереження інших типів
Коли в статті розглядаєш тільки мапи, то виникає відчуття, що тільки з мапами atomic.Value і використовують. Що ж, для слайсів і звичайних структур також підходить:
import "sync/atomic"
type CountrySettings struct {
 BlockCIDRs []string `json:"block_cidrs"`
}
type Settings struct {
 Countries map[string]CountrySettings `json:"countries"`
 PackSize  uint32                     `json:"pack_size"`
 Interval  uint32                     `json:"interval"`
 Debug     bool                       `json:"debug"`
}
type SettingsProxy struct {
 data atomic.Value
}
func NewSettingsProxy() *SettingsProxy {
 return &SettingsProxy{}
}
func (s *SettingsProxy) Update(value Settings) {
 s.data.Store(value)
}
func (s *SettingsProxy) Load() (Settings, bool) {
 var result, ok = s.data.Load().(Settings)
 return result, ok
}
import (
 "github.com/stretchr/testify/require"
 "testing"
)
func TestSettingsProxy(t *testing.T) {
 var proxy = NewSettingsProxy()
 {
  var settings, ok = proxy.Load()
  require.Equal(t, Settings{}, settings)
  require.Equal(t, false, ok)
 }
 {
  var expected = Settings{
   Countries: map[string]CountrySettings{
    "UA": {
     BlockCIDRs: nil,
    },
   },
   PackSize: 20000,
   Interval: 60,
   Debug:    true,
  }
  proxy.Update(expected)
  var actual, ok = proxy.Load()
  require.Equal(t, expected, actual)
  require.Equal(t, true, ok)
 }
}
У попередніх прикладах в конструкторі я робив збереження значення в atomic.Value, а тут навмисно пропустив, щоб вказати потребу перевірки при приведенні типу:
func (s *SettingsProxy) Load() (Settings, bool) {
 var result, ok = s.data.Load().(Settings)
 return result, ok
}
Бо інакше буде паніка:
panic: interface conversion: interface {} is nil
Такий варіант теж допустимий:
func (s *SettingsProxy) Update(value *Settings) {
 s.data.Store(value)
}
func (s *SettingsProxy) Load() (*Settings, bool) {
 var result, ok = s.data.Load().(*Settings)
 return result, ok
}
Застереження atomic.Value
Під час спроби зберегти nil чи різні типи отримуємо паніку. Ось тести, які показують таку поведінку:
func TestAtomicSuccessStoreNil(t *testing.T) {
 var atomicValue = new(atomic.Value)
 var value map[uint32]uint32 = nil
 atomicValue.Store(value)
}
func TestAtomicPanicOnStoreNil(t *testing.T) {
 defer func() {
  if r := recover(); r == nil {
   t.Errorf("The code did not panic")
  }
 }()
 var atomicValue = new(atomic.Value)
 // will panic
 atomicValue.Store(nil)
}
func TestAtomicPanicOnStoreNilInterface(t *testing.T) {
 defer func() {
  if r := recover(); r == nil {
   t.Errorf("The code did not panic")
  }
 }()
 var atomicValue = new(atomic.Value)
 var value interface{} = nil
 // will panic
 atomicValue.Store(value)
}
func TestAtomicPanicOnStoreDifferentTypes(t *testing.T) {
 defer func() {
  if r := recover(); r == nil {
   t.Errorf("The code did not panic")
  }
 }()
 var atomicValue = new(atomic.Value)
 {
  var value uint32
  // will success
  atomicValue.Store(value)
 }
 {
  var value uint32 = 1
  // will success
  atomicValue.Store(value)
 }
 {
  var value = ""
  // will panic
  atomicValue.Store(value)
 }
}
Ці застереження написані в коментарях до коду atomic.Value .
atomic.Value та збереження int32, int64, uint32, uint64
Для числових типів можна використовувати й atomic.Value:
import "sync/atomic"
type AtomicValueUint64 struct {
 data atomic.Value
}
func NewAtomicValueUint64(data uint64) *AtomicValueUint64 {
 var result = new(AtomicValueUint64)
 result.Update(data)
 return result
}
func (c *AtomicValueUint64) Update(data uint64) {
 c.data.Store(data)
}
func (c *AtomicValueUint64) Load() uint64 {
 return c.data.Load().(uint64)
}
Але можна простіше:
import "sync/atomic"
type AtomicUint64 struct {
 data uint64
}
func NewAtomicUint64(value uint64) *AtomicUint64 {
 var result = new(AtomicUint64)
 result.Update(value)
 return result
}
func (c *AtomicUint64) Update(value uint64) {
 atomic.StoreUint64(&c.data, value)
}
func (c *AtomicUint64) Load() uint64 {
 return atomic.LoadUint64(&c.data)
}
Є спеціальні функції в пакеті sync/atomic :
func LoadInt32(addr *int32) (val int32) {}
func LoadInt64(addr *int64) (val int64) {}
func LoadUint32(addr *uint32) (val uint32) {}
func LoadUint64(addr *uint64) (val uint64) {}
func StoreInt32(addr *int32, val int32) {}
func StoreInt64(addr *int64, val int64) {}
func StoreUint32(addr *uint32, val uint32) {}
func StoreUint64(addr *uint64, val uint64) {}
Епілог
У статті є повно прикладів, які трохи відрізняються, щоб краще запам’ятати. Бо коли код простий, то сам приклад зрозуміліший за текстовий опис.
Схожі оптимізації з atomic.Value для повної заміни даних знадобляться під час написання бібліотек. У робочому закритому проєкті краще використовуйте RWMutex, бо конвертація типів — це джерело помилок.
А ще під кожний тип даних треба писати окрему обгортку з RWMutex, як було у прикладах, бо в Go відсутні дженерики.
Опубліковано: 18/12/20 @ 01:00
Розділ  Різне  
Рекомендуємо:
PHP: Настраиваем отладку. PhpStorm + PHP 8 + Docker + Xdebug 3
 От шока до принятия: пять стадий тестирования API
 У що інвестують ІТ-спеціалісти і як це працює: нерухомість, бізнес, ОВДП, індексні фонди
 PHP: как удалить элемент массива по значению
 Как я работаю: Александр Гончар, Chief AI Officer в Neurons Lab