15 lat temu napisałem swoją pierwszą linijkę kodu w C#. Przeszedłem przez WebForms, WPF-a, stare ASP.NET MVC, aż po dzisiejsze wersje .NET. Popełniłem w tym czasie setki błędów i większości z nich nie żałuję, bo każdy czegoś mnie nauczył. Jest jednak jedna rzecz, której żałuję do dziś.
To nie była zła technologia ani jedna konkretna decyzja zawodowa. To był nawyk: używanie rzeczy, których nie rozumiałem. Kosztował mnie awans, oblaną rekrutację na seniora i - policzyłem to kiedyś - dobre 3 lata kariery. Widzę go dziś u wielu programistów, z którymi pracuję. Możliwe, że Ty też go masz, nawet jeśli jeszcze o tym nie wiesz.
W tym artykule pokażę Ci ten nawyk na 4 przykładach z prawdziwego kodu, takiego, jaki pewnie masz w swoim projekcie: async/await i deadlock przez .Result, LINQ i odroczone wykonanie, Entity Framework Core i problem N+1 oraz dependency injection i uwięziona zależność (captive dependency). Przy każdym przykładzie dostaniesz kod z błędem, wyjaśnienie, co dzieje się pod spodem, i poprawioną wersję. Na koniec opowiem, ile mnie ten nawyk kosztował, i dam Ci prosty plan, jak się go pozbyć w 15 minut dziennie.
Czego nie żałuję, a czego żałuję po 15 latach w .NET
Zacznę od tego, czego nie żałuję, żeby było jasne, że to nie będzie narzekanie:
• że zaczynałem od WebForms - ktoś musiał,
• żadnego frameworka, którego się uczyłem,
• żadnej zmiany pracy,
• nawet przepisywania projektu "od zera", które oczywiście trwało 3 razy dłużej, niż obiecaliśmy.
To wszystko czegoś mnie nauczyło. Żałuję jednego nawyku: latami używałem rzeczy, których nie rozumiałem. I zanim pomyślisz "ja tam wszystko rozumiem" - poczekaj, za chwilę to sprawdzimy.
Mój standardowy dzień. Przez pierwsze lata mój dzień pracy wyglądał tak:
Problem → Google / Stack Overflow → Ctrl+C, Ctrl+V → "Działa? Działa." → commit → następny ticket.
Wpisywałem problem w Google, lądowałem na Stack Overflow i kopiowałem odpowiedź z największą liczbą łapek w górę. Działa? Działa. Commit i lecę dalej. Zero refleksji, dlaczego to działa. Dziś ten nawyk wygląda identycznie, tylko zamiast Stack Overflow jest AI. Czyli jest jeszcze szybciej - i jeszcze łatwiej nie rozumieć.
"Działa" to nie to samo co "wiem, dlaczego działa". Przez lata myślałem, że to drobiazg. To nie jest drobiazg. Ta różnica to przepaść między midem a seniorem - i zaraz zobaczysz dlaczego.
Pokażę Ci to na 4 rzeczach, których używasz codziennie: async/await, LINQ, Entity Framework Core i dependency injection. Przy każdej zadam Ci jedno pytanie. Jeśli choć raz pomyślisz "o, tego nie wiedziałem" - ten artykuł jest dla Ciebie.
Błąd #1: async/await i .Result, czyli deadlock na produkcji
Wstydzę się tego, ale powiem: używałem async/await w komercyjnym kodzie przez 2 lata, zanim zrozumiałem, co naprawdę robi słówko await.
Kod, który położył mi produkcję. Poniżej uproszczona wersja kodu, który kiedyś wysłałem na produkcję. Kontroler, pobieram dane, zwracam widok:
public IActionResult Index()
{
/* "tylko szybko wezmę wynik..." */
var data = GetDataAsync().Result;
return View(data);
}
private async Task<string> GetDataAsync()
=> await _http.GetStringAsync(url);Metoda była asynchroniczna, a ja potrzebowałem wyniku "tu i teraz", więc dokleiłem .Result. Kompilator zadowolony, testy przechodzą, u mnie działa. Zanim przeczytasz dalej, spróbuj znaleźć problem. Chodzi o jedną linijkę.
Co się stało? Deadlock. Winna jest linijka z .Result. Po kolei:
1. .Result blokuje bieżący wątek i czeka na wynik.
2. W klasycznym ASP.NET await - po skończeniu pobierania - próbuje wrócić na ten sam kontekst, który właśnie zablokowałem.
3. Wątek czeka na wynik, a wynik czeka na możliwość dokończenia pracy.
Czekają na siebie nawzajem. W nieskończoność. To jest deadlock - aplikacja po prostu wisi.
A w ASP.NET Core? Tu ważna uwaga: w ASP.NET Core klasycznego deadlocka w takim przykładzie zwykle nie zobaczysz, bo nie ma tam tego samego kontekstu synchronizacji. Ale .Result dalej blokuje wątki z puli - i pod większym ruchem aplikacja zaczyna się dusić. Problem nie zniknął, tylko zmienił objawy.
Naprawa: async od góry do dołu. Naprawa jest banalnie prosta - i to w tym wszystkim najbardziej boli. Metoda staje się async, zwraca Task, a zamiast .Result jest zwykły await:
public async Task<IActionResult> Index()
{
var data = await GetDataAsync();
return View(data);
}Async ma iść od góry do dołu, przez całą aplikację. Zasada na całe życie: nie mieszaj .Result ani .Wait() z kodem asynchronicznym. Nigdy.
Lekcja #1. await nie blokuje wątku - on go zwalnia, żeby wątek mógł w tym czasie robić coś innego. Gdybym rozumiał to od początku, ten bug nigdy by nie powstał.
Błąd #2: LINQ i deferred execution - filtr, który wykonał się 3 razy
Teraz zapytanie, które wykonało się 3 razy, a ja byłem święcie przekonany, że raz.
Ile razy wykona się ten filtr? Filtruję produkty droższe niż 100 zł, a potem biorę liczbę wyników, pierwszy element i całą listę:
var expensive = products.Where(p => p.Price > 100);
var count = expensive.Count();
var first = expensive.FirstOrDefault();
var list = expensive.ToList();Pytanie: ile razy wykona się filtr z pierwszej linijki? Raz? 3 razy? A może wcale? Pomyśl chwilę, zanim przeczytasz odpowiedź.
Odpowiedź: 3 razy. Winne jest odroczone wykonanie (deferred execution):
• Where() niczego nie filtruje. Tworzy tylko definicję zapytania - przepis, a nie wynik.
• Count(), FirstOrDefault() i ToList() wykonują ten przepis - każde od nowa. 3 wywołania to 3 osobne przejścia po danych.
• A jeśli pod spodem jest baza danych? Jeśli products to zapytanie do bazy, właśnie wysłałeś 3 osobne zapytania SQL. Zupełnie niepotrzebnie.
Naprawa: zmaterializuj raz, używaj wiele razy. Materializujesz wynik raz - na przykład przez ToList() - i od tego momentu pracujesz na gotowej liście w pamięci:
var expensive = products
.Where(p => p.Price > 100)
.ToList(); /* wykonane 1 raz */
var count = expensive.Count;
var first = expensive.FirstOrDefault();Zwróć uwagę, że Count przestaje być metodą, a staje się właściwością listy. 1 wykonanie, zero niespodzianek.
Lekcja #2. LINQ nie zwraca wyników. LINQ zwraca przepis na wyniki. Jak raz to zrozumiesz, połowa "dziwnych" bugów z LINQ zniknie na zawsze.
Błąd #3: Entity Framework Core i problem N+1
Jedna niewinna pętla i 101 zapytań do bazy. Tak, 101.
Wygląda niewinnie, prawda? Pobieram zamówienia, przechodzę po nich pętlą i wypisuję nazwę klienta. Kod, jaki pisze każdy z nas:
var orders = _db.Orders.ToList(); /* zapytanie #1 */
foreach (var order in orders)
{
/* lazy loading: każdy obrót pętli */
/* to NOWE zapytanie do bazy */
Console.WriteLine(order.Customer.Name);
}Problem? Przy włączonym lazy loadingu order.Customer nie siedzi w pamięci. EF, chcąc być pomocny, przy każdym obrocie pętli po cichu wysyła do bazy nowe zapytanie.
Problem N+1. 100 zamówień to 1 zapytanie po zamówienia plus 100 zapytań po klientów: 1 + 100 = 101 zapytań SQL. Na Twoim laptopie, z bazą lokalnie, kompletnie tego nie widać - milisekundy, wszystko śmiga, commit, push, weekend. Na produkcji raport ładuje się 15 sekund i wszyscy pytają, dlaczego "system znowu muli".
I jeszcze jedno: jeśli wyłączysz lazy loading, problem nie znika magicznie. Jeśli nie załadujesz Customer jawnie, możesz dostać null albo obiekt bez danych, których się spodziewasz. To drugi klasyk z tej samej rodziny.
Naprawa: Include i kontrola SQL-a. Naprawa to jedna linijka - Include:
var orders = _db.Orders
.Include(o => o.Customer) /* JOIN */
.ToList(); /* zapytanie #1 i jedyne */W typowym przypadku EF wygeneruje wtedy JOIN-a i przywiezie wszystko jednym zapytaniem. Ważniejszy jest jednak nawyk, który wyniosłem z tej historii: zawsze podglądam SQL, który EF generuje pod spodem. Wystarczy włączyć logowanie (albo użyć interceptora). Najprościej przez LogTo w konfiguracji DbContextu:
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Default"))
.LogTo(Console.WriteLine, LogLevel.Information)); /* najlepiej tylko w developmencie */Pojedyncze zapytanie podejrzysz też metodą ToQueryString(), wywołaną na zapytaniu przed ToList(). Zajmuje to minutę, a mnie uratowało już dziesiątki razy.
Lekcja #3. ORM nie zwalnia Cię ze znajomości SQL. On go tylko chowa. A to, co schowane, prędzej czy później wybucha.
Błąd #4: dependency injection i captive dependency (uwięziona zależność)
To mój ulubiony przykład. AddScoped, AddSingleton, AddTransient - 3 metody, które każdy z nas wpisuje codziennie. Ja przez lata wpisywałem je na ślepo. Jak zaklęcia.
U mnie działa. Na produkcji wybucha. Rejestruję ReportService jako singleton - bo to raporty, niech sobie żyje 1 instancja na całą aplikację, po co tworzyć nowe obiekty. A w konstruktorze wstrzykuję AppDbContext, który, jak w każdym projekcie, jest zarejestrowany jako scoped:
builder.Services.AddSingleton<ReportService>();
/* AppDbContext jest zarejestrowany jako Scoped */
public class ReportService
{
private readonly AppDbContext _db;
public ReportService(AppDbContext db)
=> _db = db;
}Lokalnie działa idealnie. Na produkcji... różnie.
Co tu się dzieje? To się nazywa captive dependency, czyli uwięziona zależność:
1. Singleton żyje do końca aplikacji.
2. DbContext (scoped) w aplikacji webowej zwykle ma żyć przez 1 request.
3. Singleton łapie go w konstruktorze i więzi na zawsze.
4. Ten sam DbContext zaczyna obsługiwać wiele requestów naraz - a DbContext nie jest thread-safe.
Efekt: losowe wyjątki pod obciążeniem, których nie umiesz odtworzyć u siebie. Najgorszy rodzaj buga.
W tym konkretnym przypadku najprostsza naprawa to zarejestrowanie ReportService jako scoped (AddScoped). Jeśli serwis naprawdę musi być singletonem, nie powinien trzymać DbContextu w polu, tylko tworzyć go na czas jednej operacji - np. przez IDbContextFactory albo własny scope z IServiceScopeFactory.
Ściąga: cykle życia w DI. Warto mieć ją pod ręką:
| Cykl życia | Ile obiektów | Na co uważać |
| Transient | nowy obiekt przy każdym wstrzyknięciu | najluźniejszy, ale też nie zwalnia z myślenia o stanie |
| Scoped | 1 obiekt na scope - w aplikacji webowej zwykle 1 request | może korzystać ze scoped i singletonów |
| Singleton | 1 obiekt na całe życie aplikacji | nie powinien łapać scoped ani transientów z własnym stanem |
Do tego żelazna zasada: nie wstrzykuj serwisu o krótszym cyklu życia w serwis o dłuższym.
Lekcja #4. AddScoped to nie magiczne zaklęcie, które wkleja się z tutoriala. To decyzja, jak długo żyje Twój obiekt. Od kiedy podejmuję ją świadomie, ta kategoria bugów u mnie po prostu zniknęła.
Ile kosztował mnie ten nawyk
4 przykłady za nami. Teraz mniej przyjemna część: ile mnie ten nawyk kosztował. Konkrety, nie ogólniki.
Rachunek za "jakoś działa".
• 3 dni - tyle debugowałem wiszącą produkcję z deadlockiem z błędu #1. 3 dni, bo nie rozumiałem, czego szukam.
• 1 rozmowa - oblana rekrutacja na seniora (o niej za chwilę).
• ok. 3 lata - tyle dłużej siedziałem na poziomie mida. I na pensji mida. Policz sobie, ile to pieniędzy.
Najdroższa była ta ostatnia pozycja.
Rekrutacja na seniora, rok ok. 2017. Ta rozmowa wyglądała mniej więcej tak:
Rekruter: "Super, że zna pan async/await. A jak to działa w środku?"
Ja: "No... await czeka, aż wynik będzie gotowy..."
Rekruter: "OK. A co w tym czasie robi wątek?"
Ja: ...
Nic. Cisza. Tę ciszę pamiętam do dziś. Wyszedłem z tej rozmowy wściekły na nich. W drodze do domu zrozumiałem, że powinienem być wściekły na siebie. Używałem async/await codziennie - i nie umiałem powiedzieć, jak to działa.
8 razy ten sam rok. Wtedy dotarło do mnie zdanie, które boli do dziś: nie miałem 8 lat doświadczenia. Miałem 8 razy ten sam rok. Kopiowanie rozwiązań w kółko to nie jest zdobywanie doświadczenia, tylko odtwarzanie tej samej pętli.
Zasada jednego poziomu niżej
Koniec biczowania. Teraz najważniejsza część: co z tym zrobiłem. Bez studiowania kompilatorów i bez 40 godzin nauki tygodniowo - bo tyle nikt z nas nie ma.
Wszystko sprowadza się do jednej zasady, którą nazywam zasadą jednego poziomu niżej. Nie musisz schodzić do krzemu. Wystarczy, że rozumiesz 1 poziom pod abstrakcją, której używasz. Jeden, nie 10. W większości codziennej pracy to wystarcza.
Używasz? To zrozum. W praktyce wygląda to tak:
| Używasz | Zrozum |
| async/await | Task, wątki i pulę wątków |
| LINQ | IEnumerable i odroczone wykonanie |
| EF Core | SQL, który generuje pod spodem |
| DI | cykle życia obiektów |
Zauważ, że to dokładnie ta wiedza, której zabrakło we wszystkich 4 przykładach. To nie przypadek.
3 pytania, zanim czegoś użyję. Do tego 3 pytania, które zadaję sobie do dziś przy każdej nowej bibliotece czy funkcji:
1. Co to naprawdę robi? Nie "co mi daje", tylko co konkretnie się dzieje po wywołaniu.
2. Jak to działa jeden poziom niżej? Jeden poziom, nie 10. To wystarczy w 90% przypadków.
3. Kiedy i jak to się wysypie? Moje ulubione. Każda abstrakcja ma swój limit. Lepiej, żebyś Ty znalazł go pierwszy, zanim on znajdzie Ciebie na produkcji.
Plan na 15 minut dziennie
Zasada to jedno, ale potrzebny jest też konkretny plan. U mnie wszystko zmieniły 4 rzeczy, na które wystarczy 15 minut dziennie:
• Czytaj prawdziwy kod .NET. Kod .NET jest publicznie dostępny i możesz go przeglądać na source.dot.net. Na start sprawdź, jak w środku działa zwykła List<T>.
• Zobacz, co robi kompilator. Na sharplab.io wklejasz swój kod i widzisz, w co zamienia go kompilator. Jak pierwszy raz zobaczysz, co dzieje się z Twoim async/await albo LINQ, otworzą Ci się oczy.
• Debugger i Step Into. Wejdź o 1 poziom głębiej niż zwykle - także w kod, którego nie pisałeś (w Visual Studio pomoże wyłączenie opcji "Just My Code").
• Napisz mini-wersję. To najmocniejszy punkt. Własne Where() to dosłownie kilka linijek, a własny kontener DI - ok. 50. Po takim ćwiczeniu już nigdy nie patrzysz na te narzędzia tak samo.
Tak może wyglądać takie własne Where():
public static class MyLinq
{
public static IEnumerable<T> MyWhere<T>(
this IEnumerable<T> source, Func<T, bool> predicate)
{
foreach (var item in source)
{
if (predicate(item))
yield return item;
}
}
}Dzięki yield return ta metoda w chwili wywołania niczego nie filtruje - zaczyna pracować dopiero wtedy, gdy ktoś zacznie przeglądać wynik. I każde kolejne przeglądanie uruchamia pętlę od nowa. To jest właśnie odroczone wykonanie z błędu #2, tylko napisane własnoręcznie.
Zasada skopiowanego kodu. Na koniec zasada najprostsza i najskuteczniejsza. Skopiowałeś kod, obojętnie skąd: z AI, ze Stack Overflow, od kolegi? Zanim go wkleisz, wytłumacz go sobie na głos, linijka po linijce. Serio, na głos. Nie potrafisz? Nie wklejasz. Ta jedna zasada każdego dnia automatycznie wymusza zrozumienie.
"Po co mi to, skoro AI napisze kod za mnie?"
To pytanie słyszę bardzo często. Odpowiedź jest odwrotna, niż myślisz: AI właśnie odwróciło stawkę.
• Pisanie kodu stało się tanie jak nigdy - i będzie coraz tańsze.
• Ocena kodu jest dziś cenna jak nigdy, bo ktoś musi stwierdzić, czy ten kod jest dobry.
AI z tą samą pewnością siebie wygeneruje Ci świetny kod i kompletną bzdurę - z .Result w środku, z N+1, z singletonem trzymającym DbContext. Odróżnisz je tylko wtedy, gdy rozumiesz, co czytasz. Dlatego zapamiętaj: zrozumienie to jedyna umiejętność, której nie skopiujesz. Ani ze Stack Overflow, ani z AI.
I żeby było jasne: nie musisz wiedzieć wszystkiego. Nikt nie wie. Musisz rozumieć to, czego używasz codziennie. To wystarczy, żeby wejść na poziom, na który ja szedłem o kilka lat za długo.
Ściąga: 4 błędy, objawy i naprawa
Wszystkie 4 przykłady w jednym miejscu:
| Obszar | Błąd | Objaw | Naprawa | Zrozum poziom niżej |
| async/await | .Result lub .Wait() na kodzie async | deadlock (klasyczny ASP.NET), duszenie się puli wątków pod ruchem (ASP.NET Core) | async od góry do dołu, await zamiast .Result | Task, wątki i pula wątków |
| LINQ | wielokrotne wyliczanie tego samego zapytania | filtr wykonany 3 razy, przy bazie 3 zapytania SQL | zmaterializuj raz (ToList) i pracuj na liście | IEnumerable i odroczone wykonanie |
| EF Core | lazy loading w pętli | 101 zapytań zamiast 1, raport ładuje się 15 sekund | Include + podgląd generowanego SQL | SQL, który generuje EF |
| DI | scoped DbContext w singletonie | losowe wyjątki pod obciążeniem | krótszy cykl życia nigdy w dłuższym | cykle życia obiektów |
Podsumowanie
Mój największy żal po 15 latach programowania w .NET to lata używania rzeczy, których nie rozumiałem. W skrócie:
• "Działa" to nie to samo co "wiem, dlaczego działa" - i ta różnica dzieli mida od seniora.
• 4 lekcje: await zwalnia wątek, LINQ zwraca przepis na wyniki, ORM chowa SQL, a AddScoped to decyzja, jak długo żyje obiekt.
• Koszt: 3 dni debugowania produkcji, oblana rekrutacja na seniora i ok. 3 lata na poziomie mida.
• Wyjście: zasada jednego poziomu niżej, 3 pytania przed użyciem czegokolwiek, 15 minut dziennie i zasada skopiowanego kodu.
• AI: pisanie kodu tanieje, ocena kodu drożeje - a ocenić możesz tylko to, co rozumiesz.
Mam dla Ciebie jedno zadanie. Wybierz jedną rzecz, której używasz codziennie - async/await, LINQ, EF Core, DI, cokolwiek - i poświęć dziś 15 minut, żeby zrozumieć, jak działa jeden poziom niżej.
Jeśli chcesz więcej takich konkretów, dołącz do mojej społeczności .NET - jest nas już ponad 10 000 programistów. Raz w tygodniu wysyłam jeden konkretny mail: najważniejsze newsy ze świata .NET, praktyczną wiedzę taką jak w tym artykule i rabaty na moje kursy, których nie publikuję nigdzie indziej. Zero spamu, a wypisujesz się jednym kliknięciem.
Wystarczy, że zostawisz swój adres tutaj: modestprogrammer.pl/vip. To chyba najlepsze towarzystwo, żeby nie przeżywać 8 razy tego samego roku :)