Forwarded from Igor Panasyuk | IGORoutine Programming
Что будет выведено в stdout?
С точки зрения стандарта языка:
The comparison operators == and != must be fully defined for operands of the key type; thus the key type must not be a function, map, or slice. If the key type is an interface (то есть any) type, these comparison operators must be defined for the dynamic key values; failure will cause a run-time panic.
В текущем случае тип Key это произведение типов int и func. Поскольку в произведении типов присутствует тип func, то не имеет операторов сравнения и . Поэтому, согласно стандарту, получаем панику:
panic: runtime error: hash of unhashable type main.Key
https://go.dev/play/p/YgAgjKr6Jhp
The comparison operators == and != must be fully defined for operands of the key type; thus the key type must not be a function, map, or slice. If the key type is an interface (то есть any) type, these comparison operators must be defined for the dynamic key values; failure will cause a run-time panic.
В текущем случае тип Key это произведение типов int и func. Поскольку в произведении типов присутствует тип func, то
Key!===panic: runtime error: hash of unhashable type main.Key
go.dev
Go Playground - The Go Programming Language
1🔥7❤4✍1
Официальная позиция языка такая:
Goroutines do not have names; they are just anonymous workers. They expose no unique identifier, name, or data structure to the programmer. Some people are surprised by this, expecting the go statement to return some item that can be used to access and control the goroutine later.
The fundamental reason goroutines are anonymous is so that the full Go language is available when programming concurrent code. By contrast, the usage patterns that develop when threads and goroutines are named can restrict what a library using them can do.
Однако, иногда бывает нужно получить номер горутины, например, чтобы написать рекурсивный мьютекс, которого в Go нет, в отличие, например, от Java. Совсем недавно писали такой мьютекс на курсе.
Чтобы получить ID горутины, можно использовать stack trace
(Go Playground):
func Get() int64 {
var buf [64]byte
runtime.Stack(buf[:], false)
s := buf[len("goroutine "):]
s = s[:bytes.IndexByte(s, ' ')]
gid, _ := strconv.ParseInt(string(s), 10, 64)
return gid
}
Однако реализация через стектрейс довольно дорогая. Поэтому, более эффективно достать контекст горутины с помощью ассемблера, получив доступ к id:
// func getg() *g
TEXT ·getg(SB),NOSPLIT,$0-8
#ifdef GOARCH_386
MOVL (TLS), AX
MOVL AX, ret+0(FP)
#endif
#ifdef GOARCH_amd64
MOVQ (TLS), AX
MOVQ AX, ret+0(FP)
#endif
#ifdef GOARCH_arm
MOVW g, ret+0(FP)
#endif
#ifdef GOARCH_arm64
MOVD g, ret+0(FP)
#endif
#ifdef GOARCH_s390x
MOVD g, ret+0(FP)
#endif
RET
func Get() int64 {
return getg().goid
}
Более того, на гитхабе есть библиотека, которая поддерживает доступ к goroutineID для версий Go 1.3 и выше.
Важно понимать, что подобные решения не гарантируют обратную совместимость и их нужно использовать аккуратно.
Пишите в комментариях, какие темы осветить в будущих постах!
Please open Telegram to view this post
VIEW IN TELEGRAM
❤8🔥6👍3
Во сколько раз многопоточная реализация сложения матриц 4096 x 1024, распараллеливающая вычисления по строкам на 10 ядрах, быстрее однопоточной реализации?
(90 % кода распараллелено)
(90 % кода распараллелено)
Anonymous Quiz
19%
В 3 раза
20%
В 5 раз
13%
В 7 раз
14%
В 9 раз
34%
В 10 раз
3😨6🔥2❤1
Мы все видели таких коллег
«Оо, я недавно изучил пакет unsafe и concurrency, сейчас распараллелю вычисления в этом запросе и уберу лишние копии».
В лучшем случае код становится менее читаемым.
В худшем — станет медленнее
Как однажды сказал Дональд Кнут — один из отцов информатики:
«Преждевременная оптимизация — корень всех зол»
Особенно наглядно это работает для CPU-bound задач — например, сложение матриц, хэширование, кодирование. Интуитивно кажется, что «больше горутин = быстрее», но это ловушка.
Закон Амдала отлично иллюстрирует, насколько сложно получить ускорение в этом случае. Например, при 60% параллельного кода разницы между 100 и 1000 ядрами практически нет
С точки зрения практики, всегда лучше сделать бенчмарк. Как видим, ответ почти сошёлся.
BenchmarkAdd/par/w=1-12 168
BenchmarkAdd/par/w=10-12 776
При этом важно понимать, что с IO-bound задачами всё иначе — там другие принципы и прирост может быть на порядки выше.
Кстати, как раз сегодня на канале вышло видео про устройство атомиков и распараллеливание CPU-bound задач.
Если же хочется разобраться в этих принципах глубже, 25 ноября стартует 2 поток курса The Nature of Concurrency. Это системное погружение в базу многопоточности: от устройства атомиков, мьютексов до модели памяти, устройства race detector'a и lock-free алгоритмов.
Курс закрывает 100% вопросов про Concurrency на собеседованиях, видеоотзывы можно посмотреть на сайте. В честь распродажи 11.11 до конца недели действует скидка на PREMIUM тариф, на нём есть возможность поработать со мной 1 на 1, осталось всего 2 места.
Please open Telegram to view this post
VIEW IN TELEGRAM
Please open Telegram to view this post
VIEW IN TELEGRAM
4❤5🔥3💯1
Всё началось ещё давно, когда морякам необходимо было знать точное время, чтобы определить точное местоположение корабля в океане. Это связано с принципом долготы — одной из двух координат на Земле. В 1731 году изобрели хронометр, который надолго стал золотым стандартом на море. В современном мире используют спутниковые системы (GPS).
Но как же наши компьютеры понимают, сколько сейчас время? В каждом компьютере есть маленький чип — Real-Time Clock (RTC). Он работает даже тогда, когда устройство выключено. Но есть проблема в том, что RTC не очень точный, за минуту дрейф может составлять миллисекунды, а за дни и недели — уже секунды и больше.
Для решения этой проблемы был придуман Network Time Protocol (NTP). Операционная система почти всегда периодически синхронизирует время через интернет. Но откуда те, с кем мы синхронизируемся, знают точное время, как они его считают?
Существует международное атомное время (TAI - International Atomic Time). Оно равняется средневзвешенному времени цезиевых часов из разных лабораторий, с поправками на локальную разницу гравитации. Через GPS атомные часы калибруются по отдельности через положение GPS-спутников. Сами атомные часы завязаны на факт, что одна секунда равна времени, за которое атом цезия-133 совершает 9_192_631_770 переходов между энергетическими состояниями, там всё неочевидно, оставим эти вопросы физикам)
Но где же здесь всем знакомый UTC (Coordinated Universal Time)? На самом деле UTC — это шкала, которая идёт почти как TAI, но периодически в неё вставляют високосные секунды, чтобы не убегать от астрономического времени. Поскольку Земля со временем вращается медленнее, «солнечные сутки» постепенно расходятся с атомным временем. Поэтому международная служба времени
23:59:60 <- вставленная секунда
Так делали 27 раз, начиная с 1972 года. Високосная секунда кажется мелочью — всего одна лишняя секунда. Но на практике 30 июня 2012 года она вызвала крупные сбои по всему миру: в интернете, авиакомпаниях, дата-центрах, базах данных, Linux-серверах и даже в платежных системах. В частности, в ядре Linux был баг в обработке таймеров, и при вставке секунды некоторые таймеры начинали бесконечно перезапускаться, загружая CPU на 100%.
Эта секунда так всех достала, что в ноябре 2022 года на 27-й Генеральной конференции по мерам и весам было принято решение отказаться от дополнительных секунд к 2035 году или ранее))
Но как всё-таки получать точное время? Ведь даже при использовании NTP нет жёсткой гарантии на дрейф, ибо всё зависит от множества факторов (аппаратные часы, сеть, серверы) В нормальных условиях речь обычно идёт о миллисекундах, иногда десятках миллисекунд, но это всё равно слишком много, чтобы на это завязаться.
В рамках одного процесса ОС эту проблему решают монотонными часами. Например, в Go пакет time хранит такое время от операционной системы. В отличие от "реального" времени оно монотонно, то есть можно более безопасно реализовать различные таймеры.
Но что делать, если нам нужно точное время между разными компьютерами? Например, базам данных критично получать точное время для выбора меток при реализации распределённых транзакций с уровнем изоляции snapshot isolation (MVCC).
Эту проблему решают специальными чипами с атомными часами, например, Chip-Scale Atomic Clock (CSAC), который можно купить за +- 5.000$. Далее такую аппаратную поддержку используют в time server'ах, с помощью которых приложение может получить "интервал доверия" для текущего времени — диапазон, в котором оно почти наверняка находится. Сами серверы бывают разные (master, slave), соответственно, с разной точностью.
Подводя итоги, можно сказать одно. Прежде чем сильно завязаться на время в вашей системе, подумайте несколько раз, правда ли вы этого хотите
Please open Telegram to view this post
VIEW IN TELEGRAM
Please open Telegram to view this post
VIEW IN TELEGRAM
3😱7 4 4❤3
С каждым днём AI-инструменты обесценивают тривиальные знания и навыки. Никого уже не удивишь каналами, паттернами или устройством планировщика Go. Задачи становятся сложнее, требуются как знания нюансов языка, так и понимание базовых принципов. Всё это делается, чтобы отобрать максимально компетентных кандидатов среди огромного количества желающих.
Говоря про Go, почти каждое собеседование содержит задачи с concurrency. Пример одной из задач:
«Необходимо написать собственный sync.Once»
func (o *Once) Do(f func())
Проще говоря, если две горутины вызывают Do(f1) и Do(f2), по итогу только одна из функций должна выполниться, даже если f1 == f2.
Попробуем реализовать:
❗️STOP
Здесь 97% кандидатов уже проиграли, даже не начав писать код. И не потому, что не знают sync.Once, а потому что не умеют задавать вопросы и уточнять требования. Ведь корректность реализации примитива синхронизации, прежде всего, зависит от его контракта. Смотрим, что дальше
Наивная реализация выглядит так:
if o.done.CompareAndSwap(0, 1) {
f()
}
Заходим в исходники Go и видим:
// Note: Here is an incorrect implementation of Do:
//
// if o.done.CompareAndSwap(0, 1) {
// f()
// }
Почему так? Потому что изначально контракт sync.Once предполагает, что:
In the terminology of the "Go memory model", the return from f "synchronizes before" the return from any call of once.Do(f).
Проще говоря, выход из любого Do должен быть всегда после выхода из f, что не выполняется в тривиальной реализации. Здесь два варианта: либо человек это знает, либо перед написанием кода он уточняет требования, что более важно, ибо это умение не зависит от языка программирования.
Корректная реализация выглядит так:
...
if !o.done.Load() {
o.doSlow(f)
}
...
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if !o.done.Load() {
defer o.done.Store(true)
f()
}
}
Этим тонкостям на канале посвящено отдельное видео, там в том числе разбирается реализация sync.Once с примерами возникающих проблем. Более того, стала доступна запись выступления на GolangConf-X-2025, где подробно рассказывается про модель памяти.
Если же у тебя есть желание повысить свою компетентность, чтобы не попасть в те самые 97%, второй поток
Please open Telegram to view this post
VIEW IN TELEGRAM
3 10 5😱2 1 1
Data race это частный случай race condition. Определять термин data race на самом деле можно по-разному.
Самое простое определение:
Состояние во время исполнения, когда две операции над переменной происходят из разных горутин без синхронизации, при этом хотя бы одна из них является записью.
Чуть более сложное:
Состояние во время исполнения, когда две конфликтующие операции над переменной происходят из разных горутин без синхронизации.
И самое сложное на основании формализма из Go memory model:
Состояние во время исполнения, когда две конфликтующие операции над переменной нельзя разделить отношением happens before
Казалось бы, да ну их, есть прекрасный инструмент race detector, который инструментриует программу при компиляции и позволяет во время исполнения находить data race.
Но, внезапно, выясняется, что он не всегда может найти проблему. Это напрямую следует из его устройства.
Пример:
Мейнтейнеры Go пытались реализовать такой интерфейс:
type Cron interface {
Run(ctx context.Context, action func(), next func() time.Duration)
}
Реализация должна вызывать
action, ожидая перед каждым запуском next() времени. После отмены контекста не должно быть вызовов action.Такой подход на практике очень часто используется, например:
• HTTP ретраи
• Service discovery
• Adaptive polling
• Keepalive / heartbeat
• Любые задачи с backoff
Сходу у них получилась следующая реализация:
func (c *cronImpl) Run(ctx context.Context, action func(), next func() time.Duration) {
var t *time.Timer
t = time.AfterFunc(next(), func() {
select {
case <-ctx.Done():
return
default:
action()
t.Reset(next())
}
})
<-ctx.Done()
}
Запускаем тесты:
func TestCron(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
c := New()
var n atomic.Int64
next := func() time.Duration {
if n.Add(1) <= 5 {
return 0
}
return 1 * time.Second
}
var calls atomic.Int64
ctx, cancel := context.WithCancel(t.Context())
t.Cleanup(func() {
cancel()
})
done := make(chan struct{})
go func() {
defer close(done)
c.Run(ctx, func() {
calls.Add(1)
}, next)
}()
synctest.Wait()
require.Greater(t, calls.Load(), int64(0))
cancel()
synctest.Wait()
select {
case <-done:
default:
t.Fatalf("Run did not exit after cancel")
}
})
}
Которые успешно проходят даже с race detector'ом.
Но в этой реализации действительно есть data race, и race detector его не находит.
Ссылка с примером использования: https://go.dev/play/p/Fq-yNa0wXWj
P.S. Пожалуйста, те, кто проходил курс и решал это задание на практике, не пишите сразу правильный ответ)
Please open Telegram to view this post
VIEW IN TELEGRAM
7🤔5✍1
Продолжение к предыдущему посту.
Итак, разработчик из Go team написал такую реализацию:
func (c *cronImpl) Run(ctx context.Context, action func(), next func() time.Duration) {
var t *time.Timer
t = time.AfterFunc(next(), func() {
select {
case <-ctx.Done():
return
default:
action()
t.Reset(next())
}
})
<-ctx.Done()
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
c := cronImpl{}
c.Run(ctx,
func() {
fmt.Println("Hello, @igoroutine")
},
func() time.Duration {
return time.Second
},
)
}
Всё ожидаемо работает:
Hello, @igoroutine
Hello, @igoroutine
Hello, @igoroutine
Hello, @igoroutine
Hello, @igoroutine
Hello, @igoroutine
Hello, @igoroutine
Hello, @igoroutine
Hello, @igoroutine
Hello, @igoroutine
Program exited 0.
Мы, как ответственные люди, дополнительно написали тест. Более того, включили race detector при компиляции.
go test -v -race ./...
func TestCron(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
c := New()
var n atomic.Int64
next := func() time.Duration {
if n.Add(1) <= 5 {
return 0
}
return 1 * time.Second
}
var calls atomic.Int64
ctx, cancel := context.WithCancel(t.Context())
done := make(chan struct{})
go func() {
defer close(done)
c.Run(ctx, func() {
calls.Add(1)
}, next)
}()
synctest.Wait()
require.Greater(t, calls.Load(), int64(0))
cancel()
synctest.Wait()
select {
case <-done:
default:
t.Fatalf("Run did not exit after cancel")
}
})
}
=== RUN TestCron
--- PASS: TestCron (0.00s)
PASS
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x2 addr=0x0 pc=0x1009927fc]
Пробуем дебагать и снова запускать тесты. Для надёжности прогоняем их с count=10000:
go test -v -count=10000 ./...
PASS
ok 0.787s
Попробуем запустить с race detector'ом и count=100:
go test -v -race -count=100 ./...
PASS
ok 4.079s
От безысходности запускаем тесты с race detector'ом и
count=1000000 и видим вот это:
WARNING: DATA RACE
Read at 0x00c0000024a0 by goroutine 3543:
cron.go:30 +0x90 // t.Reset(next())
Previous write at 0x00c0000024a0 by goroutine 3541:
cron.go:24 +0x194 // t = time.AfterFunc(next() ...
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x2 addr=0x0 pc=0x1050ae7fc]
Всё дело в том, что AfterFunc внутри создаёт отдельную горутину:
func AfterFunc(d Duration, f func()) *Timer {
return newTimer(when(d), 0, goFunc, f, nil)
}
func goFunc(arg any, seq uintptr, delta int64) {
go arg.(func())()
}
При очень маленьком
d в этом коде
t = time.AfterFunc(next(), func() {
...
t.Reset(next())
})
Горутина с t.Reset может начать исполняться раньше, чем значение будет сохранено в t. Соответственно, получается nil pointer dereference. На самом деле причина может быть в аппаратной реализации, например, t может находиться в кэше ядра. И таких «аппаратных» объяснений может быть очень много в зависимости от архитектуры.
С точки зрения Go memory model это не важно.
Важно, что мы пишем (t = ...) и читаем (t.Reset) общую переменную без синхронизации. Это data race по определению.
И race detector не всегда может тривиально обнаружить такие проблемы, это напрямую следует из его устройства.
Более того, эту реализацию можно переписать более эффективно, чтобы избежать создания лишней горутины.
Это как раз одно из многочисленных заданий на курсе The Nature of Go.
Please open Telegram to view this post
VIEW IN TELEGRAM
9🤯6😱3✍1
В Go все привыкли писать set вот так:
type set[K comparable] struct {
data map[K]struct{}
}
Ибо многие знают, что в Go пустая структура не занимает память. Давайте посмотрим на формулировку из стандарта:
A struct or array type has size zero if it contains no fields (or elements, respectively) that have a size greater than zero.
поэтому, используя ее как значение в мапе, мы оптимизируем потребление памяти, ведь в реализации множества нам от мапы нужны только ключи.
Как было до Go 1.24?
Старая мапа была основана на бакетах, которые хранили отдельно список ключей и отдельно список значений. Грубо говоря, бакет можно было представить такой структурой:
type bmap struct {
tophash [8]uint8
keys [8]K
values [8]V
overflow *bmap
}
Массив пустых структур, как и сама пустая структура, не занимает память, значит, используя ее в качестве значения, мы выигрываем по памяти.
Что случилось в Go 1.24?
С переходом мапы на Swiss Tables структура бакетов поменялась. Теперь ключи и значения хранятся в специальных группах, но уже не в отдельных массивах, а в одной структуре:
type group struct {
ctrl uint64
slots [abi.SwissMapGroupSlots]struct {
key keyType
elem elemType
}
}
Что теперь произойдет, если использовать пустую структуру в качестве значения?
Дело в том, что, если
struct{} стоит последним полем в структуре, происходят неприятные последствия.Хотя сама пустая структура имеет нулевой размер, компилятор не может позволить указателю на последнее поле указывать за пределы объекта, поэтому он добавляет хвостовой байт (паддинг). А потом этот хвост еще может раздуться из-за выравнивания всей структуры (компилятор выравнивает структуры для большинства архитектур). То есть, теперь это поле с тем самым выравниваем будет занимать 8 байт, и наша оптимизация перестанет работать.
Как вы думаете, можно ли как-то решить эту проблему? Пишите свои варианты в комментариях.
Please open Telegram to view this post
VIEW IN TELEGRAM
4🤯13❤7😱4
Продолжение предыдущего поста (ссылка)
На самом деле, уже есть предложения по исправлению, например, поменять местами поля в структуре. Это действительно сработает, ведь компилятор не будет добавлять один байт паддинга для пустой структуры. Можно проверить это решение бенчмарками (размер слота, в котором хранятся ключи и значения, указан последним):
type group struct {
ctrl uint64
slots [abi.SwissMapGroupSlots]struct {
key keyType
elem elemType
}
}
Изначальное расположение полей:
map[addrDetail]struct {}: key=24 elem=0 slot=32
map[any]struct {}: key=16 elem=0 slot=24
map[canceler]struct {}: key=16 elem=0 slot=24
map[*conn]struct {}: key=8 elem=0 slot=16
map[GoID]struct {}: key=8 elem=0 slot=16
map[*http2serverConn]struct {}: key=8 elem=0 slot=16
map[*net.Listener]struct {}: key=8 elem=0 slot=16
map[ProcID]struct {}: key=8 elem=0 slot=16
map[string]struct {}: key=16 elem=0 slot=24
map[_typePair]struct {}: key=16 elem=0 slot=24
map[uintptr]struct {}: key=8 elem=0 slot=16
Измененное расположение полей:
map[addrDetail]struct {}: key=24 elem=0 slot=24
map[any]struct {}: key=16 elem=0 slot=16
map[canceler]struct {}: key=16 elem=0 slot=16
map[*conn]struct {}: key=8 elem=0 slot=8
map[GoID]struct {}: key=8 elem=0 slot=8
map[*http2serverConn]struct {}: key=8 elem=0 slot=8
map[*net.Listener]struct {}: key=8 elem=0 slot=8
map[ProcID]struct {}: key=8 elem=0 slot=8
map[string]struct {}: key=16 elem=0 slot=16
map[_typePair]struct {}: key=16 elem=0 slot=16
map[uintptr]struct {}: key=8 elem=0 slot=8
Размер слотов уменьшился
Проблема такого решения состоит в том, что остальная реализация мапы сильно завязана на текущем порядке полей. Во многих методах подразумевается, что в слоте ключ идет первее элемента:
// key returns a pointer to the key at index i.
func (g *groupReference) key(typ *abi.MapType, i uintptr) unsafe.Pointer {
offset := groupSlotsOffset + i*typ.SlotSize
return unsafe.Pointer(uintptr(g.data) + offset)
}
// elem returns a pointer to the element at index i.
func (g *groupReference) elem(typ *abi.MapType, i uintptr) unsafe.Pointer {
offset := groupSlotsOffset + i*typ.SlotSize + typ.ElemOff
return unsafe.Pointer(uintptr(g.data) + offset)
}
Полностью решить эту проблему разработчики обещают в Go 1.27.
UPD: Буквально сегодня замёржили фикс в master, полностью допилив решение
map[K]struct{} больше не гарантирует экономию памяти относительно map[K]bool (для некоторых типов ключей размер slots оказывается таким же)Ну а для тех, кто хочет комплексно изучить внутреннее устройство всех основных структур данных в Go, напоминаю, что в конце апреля стартует уникальный поток The Nature of Go.
Please open Telegram to view this post
VIEW IN TELEGRAM
Telegram
igoroutine.courses
😎😎 Это изменение сломало Set на map[key]struct{}, который мы знали
В Go все привыкли писать set вот так:
type set[K comparable] struct {
data map[K]struct{}
}
Ибо многие знают, что в Go пустая структура не занимает память. Давайте посмотрим на формулировку…
В Go все привыкли писать set вот так:
type set[K comparable] struct {
data map[K]struct{}
}
Ибо многие знают, что в Go пустая структура не занимает память. Давайте посмотрим на формулировку…
5 11 6 3
Что будет выведено в stdout после запуска кода?
Anonymous Quiz
19%
igoroutine courses
27%
igoroutine
35%
Вывод недетерминирован
7%
courses
11%
Программа не скомпилируется
4🔥9🤔6
Разбор задачи с порядком вызова функций в select
Что будет выведено в stdout после запуска кода?
Go Playground
На первый взгляд может показаться, что здесь вывод недетерминирован. На самом деле мы имеем дело с очень тонкой гарантией из стандарта языка Go:
Это означает, что перед попыткой записи в
Таким образом, будет выведено
На практике это означает, что нужно быть готовым к тому, что функция в case может быть вызвана, но её возвращаемые значения и side-эффекты не будут использованы в соответствующей ветке.
Что будет выведено в stdout после запуска кода?
Go Playground
На первый взгляд может показаться, что здесь вывод недетерминирован. На самом деле мы имеем дело с очень тонкой гарантией из стандарта языка Go:
For all the cases in the statement right-hand-side expressions of send statements are evaluated exactly once, in source order, upon entering the "select" statement.
Это означает, что перед попыткой записи в
ch1 и ch2 сперва будут вычислены функции get и getWithPanic, причем в порядке расположения в исходном коде.Таким образом, будет выведено
igoroutine courses, вывод из panic не учитывается, поскольку он пишется в stderr, а не stdout.На практике это означает, что нужно быть готовым к тому, что функция в case может быть вызвана, но её возвращаемые значения и side-эффекты не будут использованы в соответствующей ветке.
5 15🤯3😱3❤2