Всём доброго времени суток! Это мой первый пост ( о помощи) Работаю монтажником оконный конструкций. Зп хорошая, не жалуюсь. Но так сказать всё надоело, прикипели(работаю с 8 утра и примерно до 6 вечера, всегда по разному, ещё есть маленький и любимый сын)и понимаю, что всю жизнь не смогу так работать. Тянет меня в IT, начал многи посты читать, какие вообще направления есть. Понравился язык Golang (самообучаюсь), но не знаю правильно ли я что делаю и то ли направление выбрал и что я вообще хочу от изучения данного языка. Помощь прошу в том чтобы подсказать или посоветовать мб какие либо онлайн курсы или литературу дополнительно(читаю сейчас Донована) Извиняюсь за корявость поста Всем добра p.s. Вообще для меня сначала в идеале, это ТГ боты с AI. Но с тем временем, которое у меня есть для самообучения уйдёт лет 10, а то и больше
Горутины в языке программирования Go — это легковесные потоки, используемые для выполнения функций параллельно с другими горутинами в одном и том же адресном пространстве процесса. Горутины являются одной из ключевых особенностей Go и предоставляют очень эффективный способ для обработки параллельных задач и асинхронного выполнения.
Легковесность
Горутины очень легковесны по сравнению с традиционными потоками. Создание горутины требует всего несколько килобайт стека, который может увеличиваться и уменьшаться по мере необходимости. Это позволяет создавать тысячи и даже миллионы горутин в одном приложении без значительного потребления ресурсов.
Мультиплексирование на меньшем количестве ОС потоков
Горутины мультиплексируются на меньшем количестве потоков операционной системы. Это значит, что даже при блокировке одной горутины (например, при ожидании ввода/вывода), другие горутины продолжат выполняться на других потоках ОС, что обеспечивает высокую производительность и эффективность использования ресурсов.
Планировщик
Go имеет свой встроенный планировщик, который распределяет горутины по доступным потокам операционной системы. Планировщик использует механизм M:N, где M горутин мультиплексируются на N потоков операционной системы. Планировщик Go работает на уровне пользовательского пространства и оптимизирован для работы с большим количеством горутин.
Синхронизация и коммуникация
Для синхронизации и обмена данными между горутинами в Go используется концепция каналов. Каналы позволяют безопасно передавать сообщения между горутинами, обеспечивая при этом синхронизацию доступа к данным.
Каналы в Go — это средства для синхронизации и коммуникации между горутинами, позволяя им безопасно обмениваться данными. Работа с каналами в Go обеспечивает синхронизацию доступа к данным без необходимости использования блокировок или других механизмов синхронизации, что делает код более простым и безопасным.
Блокировка
Небуферизованные каналы блокируют отправляющую горутину до тех пор, пока другая горутина не прочитает из канала, и наоборот — получающая горутина блокируется до тех пор, пока значение не будет отправлено.
Буферизованные каналы позволяют отправлять значения в канал без блокировки до тех пор, пока буфер не будет заполнен. Аналогично, данные могут быть прочитаны из канала, если он не пуст.
Закрытие канала
Канал можно закрыть с помощью функции close, чтобы указать, что больше нет значений для отправки. После закрытия канала нельзя отправлять в него данные, но можно продолжать получать данные, которые были в нем до закрытия:
close(ch)
Попытка отправить данные в закрытый канал вызовет панику.
Проверка, закрыт ли канал
При чтении из канала можно использовать вторую переменную, чтобы проверить, закрыт ли канал:
value, ok := <-ch // ok будет false, если канал закрыт и в нем больше нет значений
Использование каналов для синхронизации
Каналы могут использоваться не только для обмена данными, но и для синхронизации выполнения горутин, например, чтобы дождаться завершения работы нескольких горутин перед продолжением выполнения основной программы.
package main
func main() {
done := make(chan bool)
go func() {
// Выполнение некоторой работы...
done <- true // Сигнализация о завершении работы
}()
<-done // Ожидание сигнала о завершении работы
}
Давайте рассмотрим простой, но весьма показательный пример, который может использоваться в продакшене: параллельную загрузку данных из нескольких источников с помощью горутин и каналов. Этот подход часто используется при работе с внешними API или при выполнении других I/O-операций, требующих асинхронности и конкурентности.
Цель
Мы хотим параллельно запросить данные из трех разных источников. Для упрощения примера представим, что эти "запросы" просто спят (time.Sleep) разное количество времени для имитации задержки сети. После "запроса" каждая горутина отправляет результат в канал. Основная горутина ожидает все результаты и затем продолжает выполнение.
Горутины, которые никогда не завершаются, могут привести к утечкам памяти. Это часто происходит, когда горутина ожидает на канале, который никогда не будет закрыт, или ожидает на блокировке, которая никогда не освободится.
Как избежать: Убедитесь, что все горутины имеют чёткие условия завершения и что все каналы, на которых они ожидают, будут в какой-то момент закрыты.
Проблемы с синхронизацией и гонки данных
Доступ к общему состоянию из нескольких горутин без надлежащей синхронизации может привести к гонкам данных, что делает поведение программы непредсказуемым.
Как избежать: Используйте мьютексы (sync.Mutex) или каналы для синхронизации доступа к общим ресурсам.
Мёртвая блокировка (Deadlock)
Мёртвая блокировка может произойти, когда две или более горутин ожидают друг друга, образуя цикл ожидания, из которого невозможно выйти.
package main
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
go func() {
<-ch1
ch2 <- 1
}()
go func() {
<-ch2
ch1 <- 1
}()
// Мёртвая блокировка: ни один из каналов не получит значение.
<-ch1 // fatal error: all goroutines are asleep - deadlock!
}
Как избежать
go vet — это инструмент командной строки в Go, предназначенный для анализа исходного кода на предмет общих ошибок, таких как гонки данных, неправильное использование синтаксиса, несоответствия типов и многое другое. Он не заменяет тесты, но может помочь выявить потенциальные проблемы в коде на ранних этапах разработки.
Race Detector - Запуск приложения с включенным детектором гонок (-race флаг компилятора) может помочь выявить некоторые виды блокировок и условий гонки, хотя его основная цель — обнаружение гонок данных.
package main
import (
"fmt"
"sync"
)
func main() {
var counter int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
counter++
wg.Done()
}()
}
wg.Wait()
fmt.Println("Final counter value:", counter)
}
// go run -race main.go
/*
WARNING: DATA RACE
Read at 0x00c0000140b8 by goroutine 7:
main.main.func1()
main.go:14 +0x33
Final counter value: 820
Found 2 data race(s)
exit status 66
*/
правильный вариант
package main
import (
"fmt"
"sync"
)
func main() {
var counter int
var wg sync.WaitGroup
var mutex sync.Mutex // Добавляем мьютекс для синхронизации
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
mutex.Lock() // Захватываем мьютекс перед изменением счётчика
counter++
mutex.Unlock() // Освобождаем мьютекс после изменения счётчика
wg.Done()
}()
}
wg.Wait()
fmt.Println("Final counter value:", counter)
}
Дебаггеры и инструменты профилирования: Использование инструментов, таких как pprof или отладчиков, которые могут помочь вам визуализировать и анализировать блокировки и другие проблемы синхронизации в вашем коде.
Создадим файл main.go с простым кодом, который намеренно создаёт нагрузку на CPU и память, чтобы мы могли увидеть что-то интересное в отчётах pprof.
Этот код запускает бесконечный цикл, который выполняет функцию computeFunction, создающую нагрузку. Кроме того, он запускает HTTP-сервер с поддержкой pprof на порту 6060. Запустите программу
go run main.go
Откройте другой терминал и используйте go tool pprof для сбора различных видов профилей (CPU, память, блокировки и т. д.) с вашей работающей программы.
Профиль использования CPU
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
Эта команда соберёт информацию о производительности CPU за последние 30 секунд.
Профиль использования памяти
go tool pprof http://localhost:6060/debug/pprof/heap
Эта команда соберёт информацию о выделении памяти программой.
После сбора профиля pprof откроет интерактивную консоль, в которой вы можете выполнять различные команды для анализа собранных данных, например:
top показывает топ функций по использованию ресурсов.
list [function name] показывает детализацию использования ресурсов для указанной функции.
web генерирует граф вызовов в виде SVG и открывает его в вашем браузере (требует Graphviz).
Эти команды помогут вам выявить узкие места в производительности и оптимизировать ваш код.
Когда вы используете pprof с Graphviz для визуализации графов на Debian (или любой другой Unix-подобной системе), местоположение сохранения графа зависит от того, как вы вызываете команду визуализации. Если вы используете команду web в интерактивном режиме pprof, она обычно открывает SVG-файл непосредственно в вашем браузере, не сохраняя его локально. Однако, если вы хотите сохранить граф в файл, вы можете использовать другие команды в pprof, такие как svg или png, для создания файла определенного формата. В интерактивном режиме pprof введите команду для сохранения в SVG:
(pprof) svg > cpu-usage.svg
Эта команда создаст файл cpu-usage.svg в текущем рабочем каталоге. Если вы хотите сохранить файл в другом месте, укажите полный путь. Аналогично, если вы предпочитаете другие форматы, такие как PNG, используйте команду png.
Внимательный анализ логики и дизайна: Часто лучший способ избежать мёртвых блокировок — это тщательно продумать дизайн вашей конкурентной логики, чтобы исключить взаимные блокировки на этапе планирования.
Дженерики (обобщенное программирование) были введены в язык программирования Go в версии 1.18. Они позволяют писать функции и типы данных, которые могут работать с различными типами данных без потери типобезопасности. До появления дженериков разработчики Go часто использовали интерфейсы и тип interface{} для создания функций и структур, способных работать с любыми типами данных, что могло привести к ошибкам времени выполнения из-за неправильного преобразования типов.
Синтаксис
Дженерики в Go определяются с использованием квадратных скобок [] после имени функции или типа. Внутри скобок указываются один или несколько типовых параметров, которые затем можно использовать в теле функции или типа как обычные типы данных.
В этом примере T является типовым параметром, который может быть заменен любым типом данных. Ключевое слово any означает, что T может быть любым типом.
Преимущества
Повторное использование кода: Дженерики позволяют создавать гибкие функции и типы данных, которые можно использовать с различными типами данных без дублирования кода.
Типобезопасность: В отличие от использования interface{}, дженерики обеспечивают проверку типов на этапе компиляции, что уменьшает риск ошибок времени выполнения.
Производительность: Использование дженериков может улучшить производительность, так как избавляет от необходимости преобразования типов и позволяет компилятору оптимизировать генерируемый код.
Ограничения
Синтаксис дженериков может быть сложным для понимания, особенно для начинающих разработчиков.
Обобщенное программирование может усложнить структуру кода и его читаемость.
Не все существующие библиотеки и инструменты уже полностью поддерживают дженерики.
Несмотря на ограничения, введение дженериков в Go значительно улучшило возможности языка для создания более гибкого и безопасного кода.
Следующий код демонстрирует применение обобщенного программирования в Go с использованием типового параметра T, ограниченного интерфейсом Signed. Интерфейс Signed определяет типы, которые могут быть одним из подписанных (signed) целочисленных типов (int, int8, int16, int32, int64). Структура NumericBox использует этот типовый параметр, что позволяет создавать экземпляры NumericBox для любого подписанного целочисленного типа.
package main
import (
"fmt"
)
type Signed interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}
// NumericBox - обобщенная структура, ограниченная для работы только с числовыми типами.
type NumericBox[T Signed] struct {
Value T
}
// NewNumericBox создает новый экземпляр NumericBox с заданным числовым значением.
// Следующая строка вызовет ошибку компиляции, так как строка не является числовым типом.
// stringBox := NewNumericBox("Hello, Generics!") // string does not satisfy Signed (string missing in ~int | ~int8 | ~int16 | ~int32 | ~int64)
}
Давайте теперь напишем замер скорости работы, простой структуры, интерфейса и Ginerics
package main
import (
"fmt"
"time"
)
type Animal interface { Get() int }
type Dog struct{ Value int }
func (d Dog) Get() int { return d.Value }
type Signed interface { ~int | ~int8 | ~int16 | ~int32 | ~int64 }
type NumericBox[T Signed] struct { Value T }
// Функция, принимающая конкретный тип.
func printInt(i int) {
fmt.Println(i)
}
// функция, принимающая интерфейс Animal.
func printInterface(animal Animal) {
fmt.Println(animal.Get())
}
// Функция, принимающая экземпляр NumericBox с любым поддерживаемым числовым типом.
func printGinerics[T Signed](box NumericBox[T]) {
fmt.Println(box.Value)
}
func main() {
// Замер производительности при использовании конкретного типа.
startConcrete := time.Now()
for i := 0; i < 1000000; i++ {
printInt(i)
}
resultConcrete := time.Since(startConcrete)
// Замер производительности при использовании интерфейса.
startInter := time.Now()
for i := 0; i < 1000000; i++ {
printInterface(Dog{Value: i})
}
resultInter := time.Since(startInter)
// Замер производительности при использовании дженерика.
startGeneric := time.Now()
for i := 0; i < 1000000; i++ {
printGinerics(NumericBox[int]{Value: i})
}
resultGeneric := time.Since(startGeneric)
fmt.Printf("Using concrete type: %v\n", resultConcrete) // Using concrete type: 869.528353ms
fmt.Printf("Using interface: %v\n", resultInter) // Using interface: 877.926898ms
fmt.Printf("Using generic: %v\n", resultGeneric) // Using generic: 841.916956ms
}
В примере обнаружено, что использование дженериков (обобщенных типов) в Go приводит к самому быстрому времени выполнения по сравнению с использованием конкретных типов и интерфейсов. Это может быть связано с несколькими факторами:
Меньшее количество преобразований типов: Когда вы используете дженерики, Go может генерировать код непосредственно для конкретного типа во время компиляции, что уменьшает необходимость в преобразованиях типов или упаковке/распаковке интерфейсов во время выполнения. Это делает операции более быстрыми.
Прямой доступ к данным: В случае дженериков, доступ к данным происходит напрямую, без дополнительного уровня абстракции, который требуется при использовании интерфейсов. Это уменьшает накладные расходы и может привести к более высокой производительности.
Отсутствие динамического поиска методов: Когда вы используете интерфейсы, Go должен выполнить поиск соответствующего метода в таблице виртуальных методов интерфейса во время выполнения. Дженерики же позволяют избежать этого, так как конкретная реализация метода известна на этапе компиляции.
Компилятор оптимизирует код для дженериков: Компилятор Go может выполнять специализацию кода для дженериков, что означает, что он может генерировать оптимизированный код для каждого конкретного случая использования.
Такую задачу поставил Little.Bit пикабушникам. И на его призыв откликнулись PILOTMISHA, MorGott и Lei Radna. Поэтому теперь вы знаете, как сделать игру, скрафтить косплей, написать историю и посадить самолет. А если еще не знаете, то смотрите и учитесь.
В Go интерфейсы представляют собой типы, определяющие наборы методов. Они используются для обеспечения полиморфного поведения объектов. В отличие от многих других языков программирования, в Go не требуется явное указание на то, что структура реализует интерфейс. Если структура имеет все методы, описанные в интерфейсе, то считается, что она его реализует.
Основные принципы работы интерфейсов:
Определение интерфейса: Интерфейс в Go описывается как набор методов. Структура или любой другой тип "реализует" интерфейс, если предоставляет реализацию всех методов, перечисленных в интерфейсе.
Полиморфизм: Интерфейсы позволяют переменным иметь различные типы значения во время выполнения программы, что обеспечивает полиморфизм. Это значит, что функция, принимающая интерфейс в качестве аргумента, может работать с любым типом, который реализует этот интерфейс.
Интерфейс{}: Пустой интерфейс interface{} не имеет методов и поэтому может представлять значение любого типа, включая встроенные типы данных. Он часто используется, когда точный тип данных заранее неизвестен.
package main
import "fmt"
// Определение интерфейса Animal
type Animal interface {
Speak() string
}
// Реализация интерфейса Animal для структуры Dog
type Dog struct {}
func (d Dog) Speak() string {
return "Woof!"
}
// Реализация интерфейса Animal для структуры Cat
type Cat struct {}
func (c Cat) Speak() string {
return "Meow"
}
// Функция, принимающая интерфейс Animal
func printAnimalSound(a Animal) {
fmt.Println(a.Speak())
}
func main() {
dog := Dog{}
cat := Cat{}
// Оба типа, Dog и Cat, реализуют интерфейс Animal
// поэтому printAnimalSound может с ними работать
printAnimalSound(dog) // Woof!
printAnimalSound(cat) // Meow
}
В этом примере Dog и Cat являются разными типами, которые реализуют интерфейс Animal, поскольку оба предоставляют метод Speak(). Функция printAnimalSound может принимать любой тип, реализующий Animal.
Подводные камни.
Производительность. Использование интерфейсов несколько снижает производительность по сравнению с работой с конкретными типами из-за необходимости решения типов во время выполнения. Давайте рассмотрим ситуацию, где производительность критична, и использование интерфейсов может влиять на производительность из-за динамического определения типов и вызова методов через интерфейсы. В таких случаях прямое использование конкретных типов может быть более эффективным.
package main
import (
"fmt"
"time"
)
// Animal интерфейс, который реализуют разные животные.
type Animal interface {
Say() string
}
// Dog структура, реализующая интерфейс Animal.
type Dog struct{}
func (d Dog) Say() string {
return "Woof!"
}
// функция, принимающая интерфейс Animal.
func makeNoise(animal Animal) {
fmt.Println(animal.Say())
}
// Функция, принимающая конкретный тип Dog.
func makeDogNoise(dog Dog) {
fmt.Println(dog.Say())
}
func main() {
dog := Dog{}
// Замер производительности при использовании интерфейса.
start_inter := time.Now()
for i := 0; i < 1000000; i++ {
makeNoise(dog)
}
result_iter := time.Since(start_inter)
// Замер производительности при использовании конкретного типа.
start := time.Now()
for i := 0; i < 1000000; i++ {
makeDogNoise(dog)
}
result_concrete := time.Since(start)
fmt.Printf("Using interface: %v\n", result_iter) // Using interface: 841.732596ms
fmt.Printf("Using concrete type: %v\n", result_concrete) // Using concrete type: 822.222233ms
}
В этом примере мы сравниваем время выполнения функций, которые работают с интерфейсом Animal и с конкретным типом Dog. Хотя разница в производительности может быть незначительной для малого количества вызовов, в высокопроизводительных системах или в системах с большим количеством вызовов эта разница может накапливаться.
Преимущества.
Полиморфизм: Интерфейсы позволяют писать функции и методы, которые могут работать с любым типом данных, реализующим интерфейс, не заботясь о конкретной реализации. Это означает, что вы можете легко заменить одну реализацию другой без изменения кода, который использует интерфейс.
Отделение интерфейса от реализации: Интерфейсы позволяют скрыть детали реализации за абстракцией. Это упрощает понимание кода, поскольку вам нужно сосредоточиться только на том, что делает код, а не как он это делает.
Упрощение тестирования: Использование интерфейсов упрощает написание модульных тестов. Вы можете легко создать "моки" или фиктивные реализации интерфейсов для тестирования, не завися от реализаций, которые могут требовать внешних зависимостей (например, базы данных).
Гибкость и расширяемость: Интерфейсы делают ваш код более гибким и легко расширяемым. Вы можете добавлять новые реализации интерфейсов без изменения существующего кода, что особенно полезно в больших и сложных системах.
Взаимозаменяемость компонентов: Поскольку компоненты системы общаются через интерфейсы, вы можете легко заменять одни компоненты другими, которые реализуют те же интерфейсы. Это особенно ценно для создания плагинов, расширений или для поддержки различных вариантов конфигурации системы.
В Go структуры являются композитным типом данных, позволяющим объединять данные различных типов в одну логическую группу. Структуры часто используются для представления объектов и записей.
Вот основы работы со структурами:
package main
import (
"fmt"
)
// Этот код определяет структуру Person с 3-мя полями: Name (строка), Age (целое число) и hidden (булево значение).
// Приватные поля в Go определяются с помощью нижнего регистра первой буквы имени поля в структуре.
// Они не доступны за пределами пакета, в котором объявлена структура.
type Person struct {
hidden bool
Name string
Age int
}
// К структурам можно привязывать методы, используя получатель (receiver):
// Вызов метода осуществляется так же, как и доступ к полям
message := p.Greet()
fmt.Println(message) // Выведет: Hello, John Doe
// Go поддерживает анонимные структуры, которые могут быть полезны для одноразовых структур данных:
var user = struct {
ID int
Name string
}{ID: 1, Name: "John Doe"}
fmt.Println(user) // Выведет: {1 John Doe}
}
В Go методы структур могут иметь два типа приемников (receiver): по значению (копия структуры) и по указателю. Выбор между ними влияет на возможность метода изменять саму структуру и на производительность. Вот основные различия:
Приемник по значению (копия)
Когда метод принимает структуру по значению, он работает с копией этой структуры. Изменения, сделанные внутри метода, не отражаются на оригинальной структуре.
Использование приемника по значению хорошо подходит для маленьких структур или в ситуациях, когда не требуется модифицировать саму структуру в методе.
Приемник по указателю
Когда метод принимает структуру по указателю, он может изменять саму структуру, поскольку работает не с копией, а с реальным адресом структуры в памяти.
Использование приемника по указателю рекомендуется, когда:
Необходимо модифицировать саму структуру в методе.
Структура достаточно большая, и копирование её значений могло бы отрицательно сказаться на производительности.
Подводные камни
package main
import "fmt"
type Example struct {
Count int
Name string
}
type Derived struct {
Example
Name string
}
type ExampleP struct {
Example *Example
}
func main() {
// Значение по умолчанию для структур
var ex Example
fmt.Println(ex.Count) // Выведет 0, значение по умолчанию для int
fmt.Println(ex.Name) // Выведет "", значение по умолчанию для string
fmt.Println(d.Name, d.Example.Name) // Чтобы получить доступ к полю Name из встроенной структуры Example, необходимо явно указать путь к нему
// Неинициализированные указатели в структурах
exp := ExampleP{}
fmt.Println(exp.Example.Name) // Приведет к панике, так как ex.Nested == nil
// Сравнение структур
// Не будет работать для структур, содержащих срезы!
ex1 := Example{Count: 5}
ex2 := Example{Count: 5}
fmt.Println(ex1 == ex2) // Выведет "true", так как все поля совпадают
}
В Go, сравнение структур с помощью оператора == будет работать только если все поля структуры поддерживают операцию сравнения. Это означает, что структуры, содержащие следующие типы данных в качестве полей, не могут быть напрямую сравнены:
Срезы (slices): Как вы уже заметили, срезы не поддерживают операцию сравнения. Попытка сравнить структуры, содержащие срезы, приведет к компиляционной ошибке.
Мапы (maps): Также как срезы, мапы не поддерживают операцию сравнения. Структуры с полями типа мапа не могут быть сравнены с помощью ==.
Функции (functions): Функции в Go также не могут быть сравнены напрямую, кроме сравнения на равенство или неравенство с nil.
Каналы (channels): Хотя каналы могут быть сравнены на равенство или неравенство, включая сравнение с nil, сложные сравнения структур, содержащих каналы, могут быть неоднозначными в зависимости от конкретной логики.
Интерфейсы с динамическими типами, не поддерживающими сравнение: Если поле интерфейса содержит значение динамического типа, который сам по себе не поддерживает сравнение, то структура, содержащая такое поле, также не может быть сравнена.
Если вам нужно сравнить структуры, содержащие такие типы данных, вам придется реализовать собственную логику сравнения, которая будет обходиться с каждым полем индивидуально, в зависимости от его типа и предполагаемого значения.
В Go, когда вы передаете структуру в функцию, по умолчанию происходит её копирование. Это значит, что внутри функции создается локальная копия структуры, и любые изменения, сделанные в этой копии, не отразятся на оригинальной структуре. Это поведение соответствует передаче аргументов по значению.
В Go массивы являются одной из базовых структур данных. Они представляют собой упорядоченную последовательность элементов одного типа, фиксированной длины. Важно понимать, что размер массива является частью его типа, что отличает массивы в Go от динамических слайсов, которые могут изменять свой размер во время выполнения программы.
Основные характеристики массивов в Go:
Фиксированный размер: Длина массива определяется при его объявлении и не может быть изменена. Это означает, что добавление элементов в массив или удаление из него невозможно без создания нового массива.
Однородность: Все элементы массива должны быть одного и того же типа.
Инициализация: Массивы могут быть инициализированы явно при объявлении или позже в коде. Также Go поддерживает инициализацию массива с помощью синтаксиса литералов.
Производительность: Доступ к элементам массива происходит очень быстро, так как элементы хранятся в непрерывной области памяти.
Объявление и инициализация массива:
var arr [5]int // Объявление массива из 5 целых чисел, инициализированного нулями
arr2 := [3]int{1, 2, 3} // Объявление и инициализация массива с тремя элементами
arr3 := [...]int{1, 2, 3, 4, 5} // Использование ... позволяет компилятору определить длину массива
Работа с элементами массива:
arr[0] = 10 // Присвоение значения первому элементу массива
fmt.Println(arr[1]) // Доступ к элементу массива
Передача массивов в функции:
Важно помнить, что массивы в Go передаются в функции по значению, что означает создание их копии. Для больших массивов это может быть неэффективно по памяти и времени. В таких случаях лучше использовать слайсы, которые являются ссылками на массивы.
func printArray(arr [3]int) {
for _, value := range arr {
fmt.Println(value)
}
}
myArray := [3]int{10, 20, 30}
printArray(myArray) // Передача массива в функцию
Использование массивов в Go имеет несколько нюансов и потенциальных "подводных камней", на которые стоит обратить внимание:
1. Фиксированный размер
Основное ограничение массивов заключается в их фиксированном размере. Размер массива является частью его типа, что означает, что вы должны знать размер массива на этапе компиляции. Это может быть неудобно в ситуациях, когда размер данных заранее неизвестен или может изменяться во время выполнения программы. В таких случаях лучше использовать слайсы, которые являются более гибкой альтернативой массивам.
2. Передача массива функции по значению
При передаче массива в функцию Go копирует все его элементы. Для больших массивов это может привести к значительным затратам памяти и времени выполнения. Чтобы избежать этого, рекомендуется использовать слайсы, поскольку они передаются по ссылке, что гораздо эффективнее.
3. Инициализация массивов неявными значениями
При объявлении массива без явной инициализации все его элементы инициализируются нулевыми значениями для типа элементов массива. Это поведение может быть неочевидным для начинающих и привести к ошибкам, если предполагается, что массив изначально заполнен другими значениями.
4. Ограниченная поддержка встроенных операций
В отличие от слайсов, для массивов в Go доступно меньше встроенных операций. Например, нельзя использовать функцию append для добавления элементов в массив, поскольку это изменит его размер, что невозможно для массивов.
5. Неудобство в использовании
Из-за строгих ограничений на размер и тип, работа с массивами может быть менее удобной, чем с слайсами, особенно когда требуется динамическое изменение размера коллекции или её частая передача между функциями.
Решения
Для избежания вышеупомянутых подводных камней рекомендуется использовать слайсы вместо массивов, когда это возможно. Слайсы обеспечивают большую гибкость и удобство при работе с коллекциями данных, позволяют динамически изменять размер и более эффективны с точки зрения использования памяти при передаче в функции.
Слайсы в Go — это удобный, динамический интерфейс к массивам. Они обеспечивают большую гибкость, по сравнению с традиционными массивами. В отличие от массивов, размер слайса не является частью его типа, что позволяет слайсам быть гораздо более динамичными.
Слайс состоит из трех компонентов:
Указатель на начальный элемент массива, к которому применяется слайс.
Длина слайса (len), указывающая на количество элементов в слайсе.
Емкость слайса (cap), которая указывает на максимальное количество элементов, начиная с текущего указателя, до конца базового массива.
Как слайсы работают
Когда вы создаете слайс, Go автоматически выделяет под него массив в памяти. Слайс предоставляет ссылку на начало этого массива и позволяет работать с его частью. Изменение элементов слайса напрямую влияет на соответствующие элементы массива, так как слайс является просто "окном" в массив.
Динамическое изменение размера
Одной из ключевых особенностей слайсов является их способность динамически изменять размер. Функция append позволяет добавлять элементы в слайс. Если при добавлении элемента емкость слайса недостаточна для размещения нового элемента, Go автоматически выделяет новый, больший массив и копирует в него элементы из старого. В результате, слайс начинает ссылаться на новый массив.
Создание и инициализация
Слайсы можно создать несколькими способами:
Используя оператор make, который позволяет указать тип, начальную длину и (необязательно) емкость слайса. Например, make([]int, 5, 10).
Через литерал слайса, например, []int{1, 2, 3}.
Оператором [:], [low:high], [low:high:max] для создания слайса из массива или другого слайса.
Подводные камни
Необходимо быть осторожным при работе со слайсами, особенно при передаче их в функции или возвращении из них, так как они могут ссылаться на одни и те же данные в памяти. Также, стоит помнить, что увеличение емкости слайса происходит путем выделения нового массива и копирования в него данных, что может быть дорогостоящей операцией с точки зрения производительности при работе с большими объемами данных.
Пара примеров с подводными камнями при работе со слайсами в Go:
1. Непреднамеренное изменение базового массива
При передаче слайса в функцию или возвращении его из функции, вы фактически передаете ссылку на базовый массив. Это значит, что изменения, сделанные в слайсе внутри функции, отразятся на исходном слайсе.
package main
import "fmt"
func modifySlice(s []int) {
s[0] = 999 // Изменяет первый элемент базового массива
}
func main() {
originalSlice := []int{1, 2, 3}
modifySlice(originalSlice)
// Вывод: [999 2 3]
fmt.Println(originalSlice)
}
В этом примере, изменяя слайс внутри функции modifySlice, мы также изменяем исходный слайс originalSlice, потому что оба они ссылаются на один и тот же базовый массив.
1.1. Решение
Нужно модифицировать функцию так, чтобы она возвращала новый слайс вместо изменения переданного ей слайса. Это дает вызывающему коду контроль над тем, должен ли исходный слайс быть изменен.
package main
import "fmt"
func CopyAndModifySlice(s []int) []int {
// Создаем новый слайс и копируем в него данные из s
2. Утечка памяти из-за неправильного срезания слайсов
Слайс хранит ссылки на базовый массив, и если вы создаете новый слайс на основе части большого массива, но при этом больше не используете исходный большой массив, он всё равно не будет собран сборщиком мусора до тех пор, пока существует хотя бы один слайс, ссылающийся на его часть.
package main
import "fmt"
func getSmallSlice() []int {
// Большой массив
bigSlice := make([]int, 1000000)
// Маленький слайс, ссылается на начало большого массива
smallSlice := bigSlice[:3]
return smallSlice
}
func main() {
smallSlice := getSmallSlice()
fmt.Println(cap(smallSlice)) // Вывод: 1000000
// В этот момент вся память, занимаемая bigSlice, все еще занята,
// несмотря на то что мы работаем только с маленьким сегментом.
}
В этом примере, bigSlice больше недоступен после выполнения getSmallSlice, но память, которую он занимает, остается занятой из-за smallSlice, который ссылается на часть bigSlice. Это может привести к неэффективному использованию памяти, особенно если подобный код выполняется многократно.
2.1. Решение
package main
import "fmt"
func getSmallSlice() []int {
// Большой массив
bigSlice := make([]int, 1000000)
// Создаем новый слайс того же размера, что и нужный маленький слайс
smallSlice := make([]int, 3)
// Копируем данные из большого массива в маленький слайс
copy(smallSlice, bigSlice[:3])
return smallSlice
}
func main() {
smallSlice := getSmallSlice()
fmt.Println(cap(smallSlice)) // Вывод: 3
// Теперь большой массив может быть собран сборщиком мусора,
// так как на него нет ссылок в виде слайса.
}
3. Увеличение емкости (capacity) слайса неявно
При добавлении элементов к слайсу его емкость может быть неявно увеличена, что приводит к созданию нового базового массива и копированию элементов из старого массива в новый. Этот процесс может быть неочевидным и привести к снижению производительности при неправильном использовании.
package main
import "fmt"
func main() {
// Слайс с начальной емкостью 1
s := make([]int, 0, 1)
prevCap := cap(s)
// Потенциально много раз увеличиваем емкость
for i := 0; i < 1024; i++ {
if cap(s) != prevCap {
fmt.Printf("Емкость изменилась с %d на %d\n", prevCap, cap(s))
prevCap = cap(s)
}
s = append(s, i)
}
}
// Емкость изменилась с 1 на 2
// Емкость изменилась с 2 на 4
// Емкость изменилась с 4 на 8
// Емкость изменилась с 8 на 16
// Емкость изменилась с 16 на 32
// Емкость изменилась с 32 на 64
// Емкость изменилась с 64 на 128
// Емкость изменилась с 128 на 256
// Емкость изменилась с 256 на 512
// Емкость изменилась с 512 на 848
// Емкость изменилась с 848 на 1280
В этом примере слайс s начинается с емкости 1 и увеличивается многократно по мере добавления элементов, что может привести к множественным аллокациям памяти и копированиям.
3.1. Решение
package main
import "fmt"
func main() {
// Заранее выделяем слайс с нужной емкостью
// Емкость сразу выделена под 1024 элемента
s := make([]int, 0, 1024)
// Добавляем элементы, не вызывая дополнительных аллокаций
for i := 0; i < 1024; i++ {
s = append(s, i)
}
// Вывод: 1024 1024
fmt.Println(len(s), cap(s))
}
// емкость не менялась не разу
4. Потенциальная потеря данных при неосторожном использовании append
Если append используется с исходным слайсом, который имеет недостаточную емкость для добавления новых элементов, будет создан новый слайс с увеличенной емкостью, и исходный слайс не будет изменен.
package main
import "fmt"
func main() {
s1 := make([]int, 0, 5)
// s2 указывает на новый слайс
s2 := append(s1, 1)
// s3 тоже указывает на новый слайс, но уже другой
s3 := append(s1, 2)
// [] [1] [2] - s1 остается без изменений
fmt.Println(s1, s2, s3)
}
Это поведение может привести к неожиданным результатам, если предполагается, что append изменит исходный слайс.
4.1. Решение
Чтобы избежать потенциальной потери данных при использовании append, особенно когда вы работаете с исходным слайсом, который может не иметь достаточной емкости для добавления новых элементов, важно убедиться, что вы корректно обрабатываете возвращаемое значение от append. append возвращает новый слайс, который может указывать на тот же или новый базовый массив в зависимости от емкости исходного слайса.
Если ваш код полагается на изменение исходного слайса, вы должны всегда присваивать результат append обратно исходному слайсу:
В роли атакующего вы должны понимать принцип работы 𝗧𝗖𝗣, чтобы справляться с разработкой пригодных вариантов его конструкций, позволяющих определять открытые/закрытые порты, распознавать такие потенциально ошибочные результаты, как ложные срабатывания — например, 𝗦𝗬𝗡-флуд защиты, — и обходить ограничения на исходящий трафик посредством переадресации портов. В этой главе вы изучите основы 𝗧𝗖𝗣-коммуникаций в 𝗚𝗼, реализуете многопоточный правильно отрегулированный сканер портов, создадите 𝗧𝗖𝗣-прокси, который можно использовать для переадресации портов, а также воссоздадите 𝗡𝗲𝘁𝗰𝗮𝘁-функцию «зияющая дыра в безопасности».
В интернете куча информации которые раскрывают каждый нюанс 𝗧𝗖𝗣, включая такие темы, как потоки и структура пакетов, надежность, повторная сборка сегментов и многие другие. Настолько подробная детализация выходит за рамки этой темы, поэтому я рекомендую глубже изучить эту тему, после прочтения статьи.
TCP Handshaking:
В качестве напоминания мы начнем с основ. снизу будет показано, как 𝗧𝗖𝗣 при запросе порта использует процесс рукопожатия (𝗵𝗮𝗻𝗱𝘀𝗵𝗮𝗸𝗶𝗻𝗴), определяя, открыт порт, закрыт или фильтруется.
Основы рукопожатия в TCP
Если порт открыт, рукопожатие осуществляется в три этапа. Сначала клиент отправляет пакет 𝘀𝘆𝗻, определяющий начало сеанса связи. В ответ на это сервер отправляет 𝘀𝘆𝗻-𝗮𝗰𝗸, иначе говоря, подтверждение получения пакета 𝘀𝘆𝗻, предлагая клиенту завершить сеанс установки связи отправкой сигнала 𝗮𝗰𝗸, то есть встречного подтверждения получения ответа сервера. После этого может начаться обмен данными. Если же порт будет закрыт, сервер ответит пакетом 𝗿𝘀𝘁, а не 𝘀𝘆𝗻-𝗮𝗰𝗸. В случае, когда трафик фильтруется межсетевым экраном (брандмауэром), клиент обычно не получает от сервера ответа.
При написании сетевых протоколов важно понимать принцип работы этих пакетов. Соответствие выходных данных создаваемых вами инструментов этим низкоуровневым потокам пакетов поможет убедиться в правильной установке сетевого соединения и устранить потенциальные проблемы. Как вы увидите чуть позже, в коде можно легко допустить ошибки, не реализовав полный цикл рукопожатия при соединении «клиент — сервер», что приведет к неточным или вводящим в заблуждение результатам.
Обход брандмауэра с помощью переадресации портов:
Иногда в системах с целью ограничения доступа клиента к конкретным серверам и портам устанавливаются брандмауэры. В некоторых случаях эти ограничения можно обойти, используя промежуточную систему для проксирования в обход или через такой брандмауэр. Эта техника называется переадресацией портов.
Многие корпоративные сети ограничивают возможность подключения своих внутренних ресурсов к вредоносным сайтам. В качестве примера представьте такой сайт под названием 𝗲𝘃𝗶𝗹.𝗰𝗼𝗺. Если сотрудник компании попытается подключиться к нему напрямую, брандмауэр заблокирует его запрос. Но если у сотрудника есть собственная внешняя система, доступная через брандмауэр (например, 𝘀𝘁𝗮𝗰𝗸𝘁𝗶𝘁𝗮𝗻.𝗰𝗼𝗺), то он может задействовать ее для установки связи с 𝗲𝘃𝗶𝗹.𝗰𝗼𝗺. Этот принцип отражен снизу.
TCP-проски
Клиент подключается к удаленному хосту 𝘀𝘁𝗮𝗰𝗸𝘁𝗶𝘁𝗮𝗻.𝗰𝗼𝗺 через брандмауэр. Этот хост настроен на перенаправление соединений к хосту 𝗲𝘃𝗶𝗹.𝗰𝗼𝗺. Несмотря на то что брандмауэр запрещает прямые подключения к 𝗲𝘃𝗶𝗹.𝗰𝗼𝗺, описанная конфигурация позволяет клиенту обойти этот механизм защиты.
Переадресацию портов можно использовать для эксплуатации нескольких запрещающих сетевых конфигураций. Например, можно перенаправить трафик через инсталляционный сервер для доступа к сегментированной сети или обращения к портам, привязанным к ограниченным интерфейсам.
Написание TCP-сканера:
Один из эффективных способов концептуализировать понимание взаимодействия 𝗧𝗖𝗣-портов — это реализация их сканера. В процессе его создания вы увидите все шаги обмена рукопожатиями в 𝗧𝗖𝗣, а также эффекты от возникающих изменений состояний, которые позволяют определить, является ли порт доступным, закрытым или отфильтровывается.
Написав базовый сканер портов, вы перейдете к созданию его ускоренной версии. Базово эта программа способна сканировать множество портов, используя один непрерывный метод, но если вам потребуется выполнить сканирование всех 𝟲𝟱 𝟱𝟯𝟱 портов, это может занять слишком много времени. Поэтому мы научим вас с помощью многопоточности делать малоэффективный сканер более подходящим для выполнения задач расширенного сканирования. Освоенные в этой статье шаблоны параллельности вы сможете применять и во многих других сценариях.
Тестирование портов на доступность:
Первый шаг в создании сканера портов — понять процесс инициирования соединения от клиента к серверу. В рассматриваемом примере вы будете подключаться к 𝘀𝗰𝗮𝗻𝗺𝗲.𝗻𝗺𝗮𝗽.𝗼𝗿𝗴 — сервису проекта 𝗡𝗺𝗮𝗽𝟭 и сканировать его. Для этого мы с вами задействуем пакет 𝗚𝗼 𝗻𝗲𝘁: 𝗻𝗲𝘁.𝗗𝗶𝗮𝗹(𝗻𝗲𝘁𝘄𝗼𝗿𝗸, 𝗮𝗱𝗱𝗿𝗲𝘀𝘀 𝘀𝘁𝗿𝗶𝗻𝗴).
Первый аргумент — это строка, определяющая тип инициируемого соединения. Дело в том, что 𝗗𝗶𝗮𝗹 используется не только для 𝗧𝗖𝗣, но и для создания соединений, задействующих сокеты 𝗨𝗻𝗶𝘅, 𝗨𝗗𝗣 и протоколы 𝟰-го уровня, которые мы оставим в стороне, так как на основе всего нашего опыта будет достаточно просто сказать, что 𝗧𝗖𝗣 очень хорош. В этот аргумент можно передать несколько вариантов строк, но для краткости будем использовать строку 𝘁𝗰𝗽.
Второй аргумент указывает 𝗗𝗶𝗮𝗹(𝗻𝗲𝘁𝘄𝗼𝗿𝗸, 𝗮𝗱𝗱𝗿𝗲𝘀𝘀 𝘀𝘁𝗿𝗶𝗻𝗴) на хост, к которому вы хотите подключиться. Обратите внимание на то, что это одна строка, а не 𝘀𝘁𝗿𝗶𝗻𝗴 и 𝗶𝗻𝘁. Для соединений 𝗜𝗣𝘃𝟰/𝗧𝗖𝗣 она будет принимать форму 𝗵𝗼𝘀𝘁:𝗽𝗼𝗿𝘁. Например, если вам нужно подключиться к 𝘀𝗰𝗮𝗻𝗺𝗲.𝗻𝗺𝗮𝗽.𝗼𝗿𝗴 через 𝗧𝗖𝗣-порт 𝟴𝟬, то нужно указать 𝘀𝗰𝗮𝗻𝗺𝗲.𝗻𝗺𝗮𝗽.𝗼𝗿𝗴:𝟴𝟬.
Теперь вы знаете, как создать соединение, но как понять, что оно было успешным? Для этого выполняется проверка на ошибки: 𝗗𝗶𝗮𝗹(𝗻𝗲𝘁𝘄𝗼𝗿𝗸, 𝗮𝗱𝗱𝗿𝗲𝘀𝘀 𝘀𝘁𝗿𝗶𝗻𝗴) возвращает Conn и error. При этом error будет nil, если соединение установлено успешно. Так что для проверки вам просто нужно убедиться, что error равна nil. Вот теперь у вас есть все необходимые элементы для построения сканера портов, хотя и не особо корректного. В Основах рукопожатия в TCP показано, как все это объединить.
Простой сканер портов, сканирующий только один порт /dial/main.go
Выполнив этот код, вы должны увидеть сообщение 𝗖𝗼𝗻𝗻𝗲𝗰𝘁𝗶𝗼𝗻 𝘀𝘂𝗰𝗰𝗲𝘀𝘀𝗳𝘂𝗹 при условии наличия у вас доступа к великой информационной супермагистрали
Выполнение однопоточного сканирования:
Сканирование по одному порту за раз не особо полезно и малоэффективно, так как диапазон 𝗧𝗖𝗣-портов — от 𝟭 до 𝟲𝟱 𝟱𝟯𝟱. В целях же тестирования давайте пока просканируем порты от 𝟭 до 𝟭𝟬𝟮𝟰. Для этого можно использовать цикл 𝗳𝗼𝗿:
Теперь у вас есть 𝗶𝗻𝘁, но нужно помнить, что в качестве второго аргумента для 𝗗𝗶𝗮𝗹(𝗻𝗲𝘁𝘄𝗼𝗿𝗸, 𝗮𝗱𝗱𝗿𝗲𝘀𝘀 𝘀𝘁𝗿𝗶𝗻𝗴) требуется строка. Есть по меньшей мере два способа преобразования целого числа в строку. Первый — использовать пакет преобразования строк 𝘀𝘁𝗿𝗰𝗼𝗻𝘃. Второй — применить функцию 𝗦𝗽𝗿𝗶𝗻𝘁𝗳(𝗳𝗼𝗿𝗺𝗮𝘁 𝘀𝘁𝗿𝗶𝗻𝗴, 𝗮 ...𝗶𝗻𝘁𝗲𝗿𝗳𝗮𝗰𝗲{}) из пакета 𝗳𝗺𝘁, которая (аналогично своему собрату в 𝗖) возвращает 𝘀𝘁𝗿𝗶𝗻𝗴, сгенерированную из строки формата (𝗳𝗼𝗿𝗺𝗮𝘁 𝘀𝘁𝗿𝗶𝗻𝗴).
Создайте файл с кодом из листинга 𝟮.𝟮 и убедитесь в работоспособности цикла и функции генерации строки. Выполнение этого кода должно вывести 𝟭𝟬𝟮𝟰 строки, но утруждать себя их подсчетом не обязательно.
Теперь осталось только подставить переменную адреса из предыдущего кода в 𝗗𝗶𝗮𝗹(𝗻𝗲𝘁𝘄𝗼𝗿𝗸, 𝗮𝗱𝗱𝗿𝗲𝘀𝘀 𝘀𝘁𝗿𝗶𝗻𝗴) и протестировать доступность портов, реализовав такую же проверку ошибок, как в предыдущем разделе. Помимо этого, чтобы не оставлять успешные соединения открытыми, следует добавить логику их закрытия. Завершение соединений — это жест вежливости. Для этого вам нужно выполнить в 𝗖𝗼𝗻𝗻 вызов 𝗖𝗹𝗼𝘀𝗲(). Снизу показана полноценная реализация сканера портов.
Скомпилируйте и выполните этот код для выполнения легкого сканирования цели. Вы должны обнаружить пару открытых портов.
Параллельное сканирование:
Предыдущий сканер сканировал серию портов в один заход. Но вашей целью является проверка множества портов параллельно, что существенно ускорит его работу. Для этого мы воспользуемся горутинами. 𝗚𝗼 позволяет создавать столько горутин, сколько способна обработать ваша система, ограничиваясь только объемом доступной памяти.
Слишком быстрая версия сканера:
Самым прямолинейным способом создания параллельного сканера будет обернуть вызов 𝗗𝗶𝗮𝗹(𝗻𝗲𝘁𝘄𝗼𝗿𝗸, 𝗮𝗱𝗱𝗿𝗲𝘀𝘀 𝘀𝘁𝗿𝗶𝗻𝗴) в горутину. Чтобы собственными глазами увидеть последствия этого, создайте файл 𝘀𝗰𝗮𝗻-𝘁𝗼𝗼-𝗳𝗮𝘀𝘁.𝗴𝗼 с кодом который снизу.
Сканер, работающий слишком быстро /tcp-scanner-too-fast/main.go
При выполнении кода вы заметите, что программа завершается практически мгновенно:
Этот код запускает по одной горутине для каждого соединения, а основная горутина не знает, что нужно ждать окончания его установки. В связи с этим выполнение кода завершается, как только цикл 𝗳𝗼𝗿 заканчивает перебор, что происходит быстрее, чем сеть успевает осуществить обмен пакетами между кодом и всеми целевыми портами. Поэтому вы не получите точных результатов для портов, чьи пакеты находились в процессе обмена.
Исправить это можно несколькими способами. Первый — использовать 𝗪𝗮𝗶𝘁𝗚𝗿𝗼𝘂𝗽 из пакета 𝘀𝘆𝗻𝗰, предоставляющий потокобезопасный способ управления параллельным выполнением. 𝗪𝗮𝗶𝘁𝗚𝗿𝗼𝘂𝗽 — это тип структуры, который создается так:
Создав 𝗪𝗮𝗶𝘁𝗚𝗿𝗼𝘂𝗽, вы можете вызвать для этой структуры несколько методов. Первый метод — 𝗔𝗱𝗱(𝗶𝗻𝘁), увеличивающий внутренний счетчик согласно переданному числу. Следующий — метод 𝗗𝗼𝗻𝗲(), уменьшающий счетчик на 𝟭. И наконец, метод 𝗪𝗮𝗶𝘁(), блокирующий выполнение горутины, в которой вызывается, запрещая дальнейЭтот вариант кода по большому счету остался неизменным. Тем не менее здесь мы добавили код, явно отслеживающий оставшуюся работу. В этой версии программышее выполнение, пока внутренний счетчик не достигнет нуля. Эти вызовы можно совмещать, гарантируя, что основная горутина дождется завершения всех соединений.
Синхронизированное сканирование с помощью WaitGroup:
Этот вариант кода по большому счету остался неизменным. Тем не менее здесь мы добавили код, явно отслеживающий оставшуюся работу. В этой версии программы
создаем 𝘀𝘆𝗻𝗰.𝗪𝗮𝗶𝘁𝗚𝗿𝗼𝘂𝗽, выступающую в качестве синхронизированного счетчика. Мы увеличиваем этот счетчик через 𝘄𝗴.𝗔𝗱𝗱(𝟭) при каждом создании горутины для сканирования порта. При этом отложенный вызов 𝘄𝗴.𝗗𝗼𝗻𝗲() уменьшает этот счетчик при завершении каждой единицы работы. Функция 𝗺𝗮𝗶𝗻() вызывает 𝘄𝗴.𝗪𝗮𝗶𝘁(), который блокирует выполнение, пока не будет выполнена вся работа и счетчик не достигнет нуля.
Эта версия программы уже лучше, но по-прежнему имеет недостатки. Если запустить ее несколько раз для разных хостов, можно получить несогласованные результаты. Одновременное сканирование чрезмерного количества хостов или портов может привести к тому, что ограничения системы или сети исказят результаты. Попробуйте изменить в коде значение 𝟭𝟬𝟮𝟰 на 𝟲𝟱𝟱𝟯𝟱 и укажите адрес целевого сервера как 𝟭𝟮𝟳.𝟬.𝟬.𝟭. При желании можете использовать 𝗪𝗶𝗿𝗲𝘀𝗵𝗮𝗿𝗸 или 𝘁𝗰𝗽𝗱𝘂𝗺𝗽, чтобы увидеть, насколько быстро открываются эти соединения.
Сканирование портов с помощью пула воркеров:
Чтобы избежать несогласованности, можно задействовать для управления параллельным выполнением пул горутин. С помощью цикла 𝗳𝗼𝗿 вы создаете определенное количество воркеров горутин в качестве пула. Затем в потоке 𝗺𝗮𝗶𝗻() с помощью канала обеспечиваете работу.
Для начала создайте новую программу, которая использует канал 𝗶𝗻𝘁, содержит 𝟭𝟬𝟬 воркеров и выводит их результаты на экран. При этом задействуйте 𝗪𝗮𝗶𝘁𝗚𝗿𝗼𝘂𝗽 для блокирования выполнения.
Создайте начальную заглушку кода для функции 𝗺𝗮𝗶𝗻, а над ней напишите функцию,приведенную в коде ниже
Функция с воркером для выполнения задачи.
Функция 𝘄𝗼𝗿𝗸𝗲𝗿(𝗶𝗻𝘁, *𝘀𝘆𝗻𝗰.𝗪𝗮𝗶𝘁𝗚𝗿𝗼𝘂𝗽) получает два аргумента: канал типа 𝗶𝗻𝘁 и указатель на 𝗪𝗮𝗶𝘁𝗚𝗿𝗼𝘂𝗽. Канал будет использоваться для получения работы, а 𝗪𝗮𝗶𝘁𝗚𝗿𝗼𝘂𝗽 — для отслеживания завершения одной ее единицы.
Далее добавьте функцию 𝗺𝗮𝗶𝗻(), приведенную в коде ниже, которая будет управлять рабочей нагрузкой и обеспечивать работу функции 𝘄𝗼𝗿𝗸𝗲𝗿(𝗶𝗻𝘁, *𝘀𝘆𝗻𝗰.𝗪𝗮𝗶𝘁𝗚𝗿𝗼𝘂𝗽).
Базовый пул воркеров /tcp-sync-scanner/main.go
Сначала создается канал с помощью 𝗺𝗮𝗸𝗲(). В 𝗺𝗮𝗸𝗲() в качестве второго параметра передается значение 𝟭𝟬𝟬. Это добавляет каналу буферизацию, то есть в него можно будет отправлять элемент и не ждать, пока получатель этот элемент прочтет. Буферизованные каналы идеально подходят для поддержания и отслеживания работы нескольких производителей и потребителей. Емкость канала определяется как 𝟭𝟬𝟬. Значит, он может вместить 𝟭𝟬𝟬 элементов, до того как отправитель будет заблокирован. Это дает небольшой прирост производительности, поскольку все воркеры смогут запускаться сразу.
Далее с помощью цикла 𝗳𝗼𝗿 запускается заданное число воркеров — в данном случае 𝟭𝟬𝟬. В функции 𝘄𝗼𝗿𝗸𝗲𝗿(𝗶𝗻𝘁, *𝘀𝘆𝗻𝗰.𝗪𝗮𝗶𝘁𝗚𝗿𝗼𝘂𝗽) с помощью 𝗿𝗮𝗻𝗴𝗲 происходит непрерывное циклическое получение данных из канала 𝗽𝗼𝗿𝘁𝘀, завершающееся только при закрытии канала. Обратите внимание: пока воркер никакой работы не выполняет — это произойдет чуть позже. Последовательно перебирая порты в функции 𝗺𝗮𝗶𝗻(), вы отправляете порт через канал 𝗽𝗼𝗿𝘁𝘀 воркеру. По завершении всей работы закрываете канал.
Запустив эту программу, вы увидите, как на экран выводятся числа. Здесь можно заметить кое-что интересное, а именно то, что выводятся они в конкретном порядке. Добро пожаловать в прекрасный мир многопоточности!
Многоканальная связь:
Чтобы завершить создание сканера, можно вставить код, использованный ранее в этом разделе, и это вполне сработает. Но в таком случае выводимые порты будут не отсортированы, так как сканер станет проверять их не по порядку. Решить эту проблему можно, реализовав упорядоченную передачу результатов сканирования в основной поток через дополнительный. Это изменение к тому же позволит полностью устранить зависимость от 𝗪𝗮𝗶𝘁𝗚𝗿𝗼𝘂𝗽, так как теперь у вас будет другой метод для отслеживания завершения. Например, если вы сканируете 𝟭𝟬𝟮𝟰 порта, то делаете по каналу воркера 𝟭𝟬𝟮𝟰 передачи, после чего снова выполняете 𝟭𝟬𝟮𝟰 передачи с результатами работы обратно в основной поток. Поскольку количество отправленных единиц работы и полученных результатов совпадает, программа понимает, когда нужно закрывать каналы и, следовательно, отключать воркеры.
Эта модификация кода представлена в коде ниже, которым завершается создание сканера.
Сканирование портов через несколько каналов /tcp-scanner-final/main.go
Функция 𝘄𝗼𝗿𝗸𝗲𝗿(𝗽𝗼𝗿𝘁𝘀, 𝗿𝗲𝘀𝘂𝗹𝘁𝘀 𝗰𝗵𝗮𝗻 𝗶𝗻𝘁) была изменена для получения двух каналов. Остальная логика почти полностью осталась прежней, за исключением того, что в случае закрытого порта вы отправляете ноль, а в случае открытого — значение этого порта. Кроме того, здесь вы создаете отдельный канал для передачи результатов от воркера в основной поток. Затем результаты сохраняются в срез, что позволяет выполнить их сортировку. Далее вам нужно реализовать отправку данных воркера в отдельной горутине, потому что цикл сбора результатов должен начаться до того, как сможет продолжиться выполнение более 𝟭𝟬𝟬 единиц работы.
Этот цикл получает по каналу 𝗿𝗲𝘀𝘂𝗹𝘁𝘀 𝟭𝟬𝟮𝟰 передачи. Если порт не равен 𝟬, он добавляется в срез. После закрытия каналов вы используете сортировку для упорядочивания среза открытых портов. Далее остается лишь перебрать срез и вывести открытые порты на экран.
Вот мы и написали высокопроизводительный сканер портов. Уделите время экспериментированию с кодом — в частности, с количеством воркеров. Чем их больше, тем быстрее должна выполняться программа. Но если их окажется слишком много, результаты станут ненадежными. При написании инструментов, которые будут применять другие люди, вам нужно использовать грамотное предустановленное значение, которое ориентировано на надежность, а не на скорость. При этом также следует предоставлять пользователям опцию самостоятельного выбора количества воркеров.
В полученную программу можно внести пару улучшений. Во-первых, вы отправляете по каналу 𝗿𝗲𝘀𝘂𝗹𝘁𝘀 результат сканирования каждого порта, что необязательно. Альтернативное решение потребует написания более сложного кода, который будет использовать дополнительный канал не только для отслеживания воркеро. Вам может потребоваться, чтобы сканер умел парсить строки с портами, например 𝟴𝟬,𝟰𝟰𝟯,𝟴𝟬𝟴𝟬,𝟮𝟭-𝟮𝟱, наподобие тех, что могут быть переданы в 𝗡𝗺𝗮𝗽. Я предлагаю вам освоить этот прием самостоятельно.
P.S: в следующей статье мы рассмотрим создание TCP-прокси.