Environment, Process ve Stopwatch: Ortam Bilgisi, Dış Komut Çalıştırmak ve Süre Ölçmek
Harici bir komutu .NET'ten güvenle çalıştırmanın üç kuralı var: argümanları ArgumentList ile ver, stdout ve stderr'i aynı anda oku, exit code'u ve timeout'u kontrol et. Bookstore'un gece raporu aracı üzerinden Environment ve RuntimeInformation'ı, ortam değişkenlerini, SpecialFolder ve AppContext switch'lerini, Process'teki deadlock ve injection tuzaklarını, .NET 11'in Process.RunAsync API'lerini ve Stopwatch ile süre ölçmeyi gerçek çıktılarla gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; .NET 11 bölümündeki örnekler .NET 11 SDK (11.0.100) ile çalıştırılmıştır. Çıktılar, makine adı app1 ve kullanıcısı app olan bir Linux x64 container'ından alınmıştır.
Önce Tahmin Et
Bookstore'un (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) gece raporunu üreten konsol aracı, raporları hangi klasöre yazacağını BOOKSTORE_REPORT_DIR ortam değişkeninden okuyor. Değişken tanımlı ve değeri /tmp/reports. Kod Linux'ta çalışıyor; üç satır ne yazar, Windows'ta ne değişir?
// BOOKSTORE_REPORT_DIR ortamda tanımlı: /tmp/reports
Console.WriteLine(Environment.NewLine.Length);
Console.WriteLine(Path.Combine("reports", "2026-11-14.csv"));
Console.WriteLine(Environment.GetEnvironmentVariable("bookstore_report_dir") ?? "(null)");
1
reports/2026-11-14.csv
(null)
Linux'ta satır sonu tek karakter (\n), yol ayırıcısı / ve ortam değişkeni adları büyük/küçük harfe duyarlı; küçük harfle yazılan ad bulunamadı. Windows'ta aynı kod 2 (\r\n), reports\2026-11-14.csv ve değişkenin değerini yazar, çünkü Windows ortam değişkeni adlarını harf duyarsız karşılaştırır. Geliştirici Windows'ta yazıp test ettiği kodu Linux container'ına koyduğunda ilk sürprizlerden biri budur: kod hata vermez, yalnız ayarı "görmez" ve varsayılan değerle çalışır.
Bu yazıda uygulamanın içinde çalıştığı ortama bakıyoruz: Environment ve RuntimeInformation ile platform bilgisi, ortam değişkenleri, özel klasörler ve AppContext switch'leri; sonra Process ile harici bir komutu çalıştırmak, çıktısını almak ve sık yapılan iki hatadan (deadlock ve argüman injection'ı) kaçınmak; .NET 11'in yeni Process API'leri; en sonda da Stopwatch ile süre ölçmek. Örnekler Bookstore'un gece raporu aracından:
dotnet run --project samples/Bookstore.Bcl.Samples -- env-info
Environment ve RuntimeInformation
Uygulamanın hangi işletim sisteminde, hangi runtime ile ve kaç CPU görerek çalıştığını bilmek log'ların ilk satırı için de, platforma göre davranış seçmek için de gerekir:
Console.WriteLine($"OperatingSystem.IsWindows() : {OperatingSystem.IsWindows()}");
Console.WriteLine($"OperatingSystem.IsLinux() : {OperatingSystem.IsLinux()}");
Console.WriteLine($"OSDescription : {RuntimeInformation.OSDescription}");
Console.WriteLine($"FrameworkDescription : {RuntimeInformation.FrameworkDescription}");
Console.WriteLine($"RuntimeIdentifier : {RuntimeInformation.RuntimeIdentifier}");
Console.WriteLine($"OS / Process architecture : {RuntimeInformation.OSArchitecture} / {RuntimeInformation.ProcessArchitecture}");
Console.WriteLine($"Is64BitProcess : {Environment.Is64BitProcess}");
Console.WriteLine($"ProcessorCount : {Environment.ProcessorCount}");
Console.WriteLine($"MachineName / UserName : {Environment.MachineName} / {Environment.UserName}");
Console.WriteLine($"ProcessId : {Environment.ProcessId}");
Console.WriteLine($"Version : {Environment.Version}");
OperatingSystem.IsWindows() : False
OperatingSystem.IsLinux() : True
OSDescription : Ubuntu 24.04.5 LTS
FrameworkDescription : .NET 10.0.15
RuntimeIdentifier : linux-x64
OS / Process architecture : X64 / X64
Is64BitProcess : True
ProcessorCount : 4
MachineName / UserName : app1 / app
ProcessId : 14
Version : 10.0.15
Platform kontrolü için OperatingSystem.IsWindows(), IsLinux(), IsMacOS() ve sürüm karşılaştıran IsWindowsVersionAtLeast(10, 0, 19041) gibi method'ları kullan. Bunlar CA1416 analyzer'ının (platforma özgü bir API'nin her platformdan çağrılabildiğini bildiren kural) tanıdığı kontrollerdir; if (OperatingSystem.IsWindows()) bloğunun içinde Windows'a özgü bir API çağırdığında uyarı kaybolur. RuntimeInformation.IsOSPlatform(OSPlatform.Windows) daha eski bir yol ve aynı işi yapar. OSDescription yalnız log içindir; içindeki metni parse edip karar verme.
ProcessorCount container'da önemli bir ayrıntı: bu çıktı --cpus 4 ile başlatılmış bir container'dan ve makinedeki fiziksel çekirdek sayısını değil, container'a verilen CPU sınırını gösteriyor. .NET, Linux'ta cgroup (Linux çekirdeğinin process gruplarına CPU ve bellek sınırı koyan mekanizması) sınırlarını okur; thread pool ve Parallel varsayılanları bu sayıya göre ayarlanır. MachineName container'ın hostname'idir ve bir pod yeniden başladığında değişir; log'a yazmak için iyi, bir kaynağın kimliği olarak saklamak için kötü bir değerdir.
Özel Klasörler
Rapor, cache ya da ayar dosyasının nereye yazılacağını sabit bir yolla kodlamak yerine Environment.GetFolderPath kullanılır. Ama Linux'ta bir ayrıntı var:
Console.WriteLine($"CurrentDirectory : {Environment.CurrentDirectory}");
Console.WriteLine($"AppContext.BaseDirectory: {AppContext.BaseDirectory}");
Console.WriteLine($"GetTempPath() : {Path.GetTempPath()}");
Environment.SpecialFolder[] folders =
[
Environment.SpecialFolder.UserProfile,
Environment.SpecialFolder.ApplicationData,
Environment.SpecialFolder.LocalApplicationData,
Environment.SpecialFolder.CommonApplicationData,
Environment.SpecialFolder.ProgramFiles,
];
foreach (Environment.SpecialFolder folder in folders)
{
string verified = Environment.GetFolderPath(folder);
string doNotVerify = Environment.GetFolderPath(folder, Environment.SpecialFolderOption.DoNotVerify);
Console.WriteLine($"{folder,-22}: \"{verified}\" / DoNotVerify: \"{doNotVerify}\"");
}
CurrentDirectory : /app
AppContext.BaseDirectory: /app/
GetTempPath() : /tmp/
UserProfile : "/home/app" / DoNotVerify: "/home/app"
ApplicationData : "" / DoNotVerify: "/home/app/.config"
LocalApplicationData : "" / DoNotVerify: "/home/app/.local/share"
CommonApplicationData : "/usr/share" / DoNotVerify: "/usr/share"
ProgramFiles : "" / DoNotVerify: ""
GetFolderPath varsayılan olarak klasörün var olup olmadığını kontrol eder ve yoksa boş string döner. Yeni bir container'da ~/.config ve ~/.local/share henüz oluşturulmadığı için ApplicationData ve LocalApplicationData boş geldi; Path.Combine("", "bookstore") de sessizce göreli bir yol üretir ve dosya çalışma klasörüne yazılır. SpecialFolderOption.DoNotVerify yolu kontrol etmeden verir, Create ise klasörü oluşturur. Windows'ta aynı değerler C:\Users\user\AppData\Roaming ve C:\Users\user\AppData\Local olur ve genelde zaten vardır, bu yüzden hata Windows'ta hiç görünmez. ProgramFiles gibi Linux karşılığı olmayan klasörler her iki seçenekle de boş döner. Bir de CurrentDirectory ile AppContext.BaseDirectory farkı var: ilki process'i başlatanın bulunduğu klasördür ve bir Windows service ya da scheduled task için genelde C:\Windows\System32 olur; uygulamanın yanındaki bir dosyayı okuyacaksan AppContext.BaseDirectory kullan.
Ortam Değişkenleri ve AppContext Switch'leri
Gece raporu aracı iki ayarı ortam değişkeninden okuyor: rapor klasörü ve raporların kaç gün saklanacağı.
string reportDir = Environment.GetEnvironmentVariable("BOOKSTORE_REPORT_DIR") ?? Path.Combine(Path.GetTempPath(), "bookstore-reports");
Console.WriteLine($"BOOKSTORE_REPORT_DIR : {reportDir}");
string? rawRetention = Environment.GetEnvironmentVariable("BOOKSTORE_REPORT_KEEP_DAYS");
int keepDays = int.TryParse(rawRetention, out int parsed) && parsed > 0 ? parsed : 30;
Console.WriteLine($"BOOKSTORE_REPORT_KEEP_DAYS: raw={rawRetention ?? "(null)"} -> {keepDays}");
Environment.SetEnvironmentVariable("BOOKSTORE_RUN_ID", "nightly-2026-11-14");
Console.WriteLine($"After Set (process) : {Environment.GetEnvironmentVariable("BOOKSTORE_RUN_ID")}");
int bookstoreVariables = Environment.GetEnvironmentVariables().Keys.Cast<string>().Count(k => k.StartsWith("BOOKSTORE_", StringComparison.Ordinal));
Console.WriteLine($"BOOKSTORE_* variables : {bookstoreVariables}");
try
{
Environment.SetEnvironmentVariable("BOOKSTORE_RUN_ID", "x", EnvironmentVariableTarget.User);
Console.WriteLine($"Target=User on Unix : no exception, read back: {Environment.GetEnvironmentVariable("BOOKSTORE_RUN_ID", EnvironmentVariableTarget.User) ?? "(null)"}");
}
catch (Exception ex)
{
Console.WriteLine($"Target=User : {ex.GetType().Name}");
}
BOOKSTORE_REPORT_DIR : /tmp/reports
BOOKSTORE_REPORT_KEEP_DAYS: raw=(null) -> 30
After Set (process) : nightly-2026-11-14
BOOKSTORE_* variables : 2
Target=User on Unix : no exception, read back: (null)
Ortam değişkeni her zaman string ya da null'dır; sayıya çevirirken TryParse kullan ve geçersiz değerde ne olacağına karar ver. Burada sessizce varsayılana düştük; bir production ayarında bunun yerine uygulamayı açılışta durdurmak genelde daha doğrudur, çünkü "30 gün" ile çalışan ama 7 gün sanılan bir job'ı kimse fark etmez. SetEnvironmentVariable yalnız çalışan process'in kopyasını değiştirir; bu process'in başlattığı child process'ler yeni değeri görür, kabuk ya da diğer uygulamalar görmez. EnvironmentVariableTarget.User ve Machine yalnız Windows'ta registry'e yazar; Unix'te çağrı exception vermeden yok sayılır, çıktıdaki (null) bunu gösteriyor.
ASP.NET Core ya da Generic Host kullanan bir uygulamada ortam değişkenlerini doğrudan okumak yerine configuration sistemine bırakmak daha iyi: Bookstore__Reports__Dir gibi adlar otomatik olarak Bookstore:Reports:Dir anahtarına bağlanır, appsettings ve command line ile aynı öncelik sırasına girer. Bu sıra ASP.NET Core Web API serisinin "ASP.NET Core'da Configuration ve Options Pattern" yazısında anlatılıyor. Environment.GetEnvironmentVariable host'u olmayan küçük araçlar ve uygulama açılmadan önce okunması gereken birkaç değer için uygun.
AppContext Switch'leri
.NET'in kendi davranış ayarları (globalization invariant mode, server GC (garbage collector; kullanılmayan belleği geri toplayan mekanizma) gibi) build sırasında <uygulama>.runtimeconfig.json dosyasına yazılır ve çalışırken AppContext üzerinden okunur. Aynı mekanizmayı kendi ayarların için de kullanabilirsin. Samples projesinin csproj'una bir satır ekledim:
<ItemGroup>
<!-- Bookstore.Bcl.Samples.runtimeconfig.json'a yazılır; AppContext.TryGetSwitch ile okunur. -->
<RuntimeHostConfigurationOption Include="Bookstore.Reports.UseLegacyCsv" Value="true" />
</ItemGroup>
bool found = AppContext.TryGetSwitch("Bookstore.Reports.UseLegacyCsv", out bool useLegacyCsv);
Console.WriteLine($"Bookstore.Reports.UseLegacyCsv : found={found}, value={useLegacyCsv}");
found = AppContext.TryGetSwitch("System.Globalization.Invariant", out bool invariant);
Console.WriteLine($"System.Globalization.Invariant : found={found}, value={invariant}");
Console.WriteLine($"System.GC.Server (GetData) : {AppContext.GetData("System.GC.Server") ?? "(null)"}");
AppContext.SetSwitch("Bookstore.Reports.IncludeCancelled", true);
AppContext.TryGetSwitch("Bookstore.Reports.IncludeCancelled", out bool includeCancelled);
Console.WriteLine($"Set in code : {includeCancelled}");
Bookstore.Reports.UseLegacyCsv : found=True, value=True
System.Globalization.Invariant : found=False, value=False
System.GC.Server (GetData) : (null)
Set in code : True
TryGetSwitch'in dönüş değeri ile switch'in değeri ayrı şeyler: found=False "kimse bu ayarı vermedi, varsayılan geçerli" demek. Invariant mode ve server GC bu projede ayarlanmadığı için ikisi de bulunamadı. AppContext switch'leri, bir kütüphanenin yeni sürümündeki davranış değişikliğini kapatmak için kullanılan bir uyumluluk mekanizması olarak tasarlandı; .NET'in kendi breaking change notlarında da "eski davranışa dönmek için şu switch'i ver" diye geçerler. Uygulamanın iş ayarları için configuration sistemi daha uygun; switch'i kendi kütüphanende, kullanıcısına geri dönüş yolu bırakmak istediğinde kullan.
Process.Start ve ProcessStartInfo
Gece raporu aracı ürettiği CSV (değerleri ayırıcıyla sıralayan düz metin formatı) dosyalarını sıkıştırmak için harici bir araç çağırıyor. tar hem Linux'ta hem Windows 10 ve sonrasında (tar.exe) bulunduğu için iyi bir aday:
ProcessStartInfo startInfo = new("tar")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
WorkingDirectory = dir,
};
startInfo.ArgumentList.Add("-czf");
startInfo.ArgumentList.Add("nightly-2026-11-14.tar.gz");
startInfo.ArgumentList.Add("orders.csv");
using Process process = Process.Start(startInfo)!;
Task<string> stdout = process.StandardOutput.ReadToEndAsync();
Task<string> stderr = process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
Console.WriteLine($"tar -czf: exit={process.ExitCode} stdout=\"{await stdout}\" stderr=\"{await stderr}\"");
ToolResult list = await ExternalTool.RunAsync("tar", ["-tzf", "nightly-2026-11-14.tar.gz"], TimeSpan.FromSeconds(10), dir);
Console.WriteLine($"tar -tzf: exit={list.ExitCode} files={list.StandardOutput.Trim()}");
ToolResult missing = await ExternalTool.RunAsync("tar", ["-czf", "broken.tar.gz", "missing.csv"], TimeSpan.FromSeconds(10), dir);
Console.WriteLine($"missing : exit={missing.ExitCode}");
Console.Write($"stderr : {missing.StandardError}");
tar -czf: exit=0 stdout="" stderr=""
tar -tzf: exit=0 files=orders.csv
missing : exit=2
stderr : tar: missing.csv: Cannot stat: No such file or directory
tar: Exiting with failure status due to previous errors
Başarının tek güvenilir işareti exit code'dur. tar eksik dosyada 2 döndü ve hata mesajını stderr'e yazdı; stdout boş. Exit code'u kontrol etmeyen kod "tar çalıştı" diye devam eder ve arşivin eksik olduğunu kimse fark etmez. Windows'taki tar.exe başka bir uygulama (bsdtar) olduğu için mesajlar farklıdır, ama exit code sözleşmesi aynıdır: 0 başarı, gerisi hata.
Üç ayar her çağrıda bilinçli seçilmeli. UseShellExecute .NET Core'dan beri varsayılan olarak false'tur (.NET Framework'te true idi): process doğrudan başlatılır, çıktı yönlendirilebilir. true yalnız bir dosyayı ya da URL'i işletim sisteminin varsayılan uygulamasıyla açmak için gerekir ve bu durumda çıktı yönlendirilemez. ArgumentList her argümanı ayrı bir eleman olarak verir ve platforma göre doğru şekilde tırnaklar; tek string alan Arguments property'sinin neden tehlikeli olduğunu birazdan göreceğiz. WaitForExitAsync process bitene kadar thread'i bloklamadan bekler ve bir CancellationToken alır.
stdout ve stderr'i Aynı Anda Okumak
Yönlendirilen her çıktı bir pipe'tır ve pipe'ın bir buffer'ı vardır (Linux'ta varsayılan 64 KB). Child process buffer dolunca yazmayı bekler. Parent yalnız stdout'u okuyup stderr'e hiç bakmazsa ve child stderr'e çok yazarsa, ikisi birbirini bekler. Bunu göstermek için örnek uygulama kendini proc-noisy argümanıyla tekrar çalıştırıyor; bu mod stderr'e 1 MB uyarı, stdout'a tek satır yazıyor:
using (Process child = Process.Start(SelfStartInfo("proc-noisy"))!)
{
try
{
// Yanlış: önce stdout'u sonuna kadar oku, stderr'e sonra bak.
string output = await child.StandardOutput.ReadToEndAsync().WaitAsync(TimeSpan.FromSeconds(5));
Console.WriteLine($"stdout: {output}");
}
catch (TimeoutException)
{
Console.WriteLine($"stdout-first: no result after 5 s, child exited: {child.HasExited}");
child.Kill(entireProcessTree: true);
await child.WaitForExitAsync();
}
}
ProcessStartInfo self = SelfStartInfo("proc-noisy");
ToolResult result = await ExternalTool.RunAsync(self.FileName, self.ArgumentList, TimeSpan.FromSeconds(30));
Console.WriteLine($"both pipes : exit={result.ExitCode} stdout=\"{result.StandardOutput.Trim()}\" stderr={result.StandardError.Length:N0} chars");
stdout-first: no result after 5 s, child exited: False
both pipes : exit=0 stdout="report done" stderr=1,048,576 chars
İlk denemede 5 saniyelik sınırı ben koydum; koymasaydım process sonsuza kadar asılı kalırdı ve gece job'ı sabah hâlâ "çalışıyor" görünürdü. Bu hata test ortamında çoğu zaman görünmez, çünkü orada araç az uyarı üretir ve buffer dolmaz. Çözüm iki pipe'ı aynı anda okumak. Bookstore kütüphanesindeki ExternalTool bunu yapıyor ve bir timeout da uyguluyor:
long started = Stopwatch.GetTimestamp();
using Process process = Process.Start(startInfo)
?? throw new InvalidOperationException($"Could not start '{fileName}'.");
// İki pipe'ı aynı anda oku; birini sonuna kadar okumak deadlock'a yol açabilir.
Task<string> stdout = process.StandardOutput.ReadToEndAsync(CancellationToken.None);
Task<string> stderr = process.StandardError.ReadToEndAsync(CancellationToken.None);
using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeout);
bool timedOut = false;
try
{
await process.WaitForExitAsync(timeoutCts.Token);
}
catch (OperationCanceledException)
{
process.Kill(entireProcessTree: true);
await process.WaitForExitAsync(CancellationToken.None);
cancellationToken.ThrowIfCancellationRequested();
timedOut = true;
}
string[] output = await Task.WhenAll(stdout, stderr);
return new ToolResult(process.ExitCode, output[0], output[1], Stopwatch.GetElapsedTime(started), timedOut);
Timeout ya da iptal olduğunda Kill(entireProcessTree: true) yalnız başlattığımız process'i değil, onun başlattıklarını da durdurur; bir script'in arka planda bıraktığı process'ler böylece sahipsiz kalmaz. Kill'den sonra process'in gerçekten bitmesini beklemek de gerekiyor, yoksa ExitCode okunamaz. Çağıranın iptali ile kendi timeout'umuzu ayırmak için linked bir CancellationTokenSource kullandık: iptal çağırandan geldiyse exception fırlatıyoruz, timeout ise sonuçta TimedOut=true olarak dönüyor. Çok büyük çıktılarda her şeyi string'de toplamak yerine OutputDataReceived event'i ya da satır satır okuma ile çıktıyı akıtmak gerekir.
Argümanlar, Shell ve Injection
Argümanları tek bir string'de birleştirmek en sık yapılan hatadır. Q4 (yılın dördüncü çeyreği) raporunun dosya adında boşluk var:
// Yanlış: argümanları tek string'de birleştirmek.
ProcessStartInfo joined = new("tar", $"-czf q4.tar.gz {fileName}") { WorkingDirectory = dir, RedirectStandardError = true };
using (Process process = Process.Start(joined)!)
{
string error = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
Console.WriteLine($"Arguments string: exit={process.ExitCode}");
Console.Write(error);
}
ToolResult listed = await ExternalTool.RunAsync("tar", ["-czf", "q4.tar.gz", fileName], TimeSpan.FromSeconds(10), dir);
Console.WriteLine($"ArgumentList : exit={listed.ExitCode}");
Arguments string: exit=2
tar: Q4: Cannot stat: No such file or directory
tar: report.csv: Cannot stat: No such file or directory
tar: Exiting with failure status due to previous errors
ArgumentList : exit=0
"Q4 report.csv" iki ayrı argümana bölündü. Burada sonuç yalnız başarısız bir arşiv; ama dosya adını kullanıcı veriyorsa, - ile başlayan bir ad tar'a yeni bir seçenek olarak geçebilir. ArgumentList her elemanı tek bir argüman olarak iletir ve Windows'ta gereken tırnaklamayı kendisi yapar.
Daha tehlikeli durum, komutu bir shell üzerinden çalıştırmak. Kitap başlığını sh -c ile kurulan bir komuta gömersek:
string title = "Dune; echo INJECTED";
// Yanlış: kullanıcı verisini shell komutuna gömmek.
ToolResult viaShell = await ExternalTool.RunAsync("sh", ["-c", $"echo Archiving {title}"], TimeSpan.FromSeconds(10));
Console.Write($"sh -c (concatenated):\n{viaShell.StandardOutput}");
ToolResult asArgument = await ExternalTool.RunAsync("sh", ["-c", "echo Archiving \"$1\"", "sh", title], TimeSpan.FromSeconds(10));
Console.Write($"sh -c (positional \"$1\"):\n{asArgument.StandardOutput}");
sh -c (concatenated):
Archiving Dune
INJECTED
sh -c (positional "$1"):
Archiving Dune; echo INJECTED
Başlıktaki ; shell için komut ayırıcısıdır ve ikinci komut çalıştı. echo yerine rm ya da bir curl olabilirdi. Windows'ta cmd.exe /c ile aynı sorun & ve | karakterleriyle oluşur. Bu, Web Güvenliği serisinde anlatılan injection ailesinin işletim sistemi komutu versiyonu (OS command injection). En iyi çözüm shell'i hiç kullanmamak: aracı doğrudan başlat ve argümanları ArgumentList ile ver. Shell gerçekten gerekiyorsa (pipe, yönlendirme), kullanıcı verisini komut metnine değil, ikinci örnekteki gibi ayrı bir argüman ("$1") olarak geçir. Bu örnek sh gerektirdiği için Linux ve macOS'ta çalışır.
.NET 11: Process.RunAsync ve RunAndCaptureTextAsync
Yukarıdaki ExternalTool class'ını neredeyse her ekip bir gün yazar. .NET 11 bu işi BCL'ye (Base Class Library; her .NET uygulamasıyla gelen temel class library) taşıdı. Aşağıdaki örnek samples/Bookstore.Bcl.Net11 projesinde ve .NET 11 runtime'lı bir Linux container'ında çalıştı:
ProcessStartInfo tar = new("tar", ["-czf", "nightly.tar.gz", "orders.csv", "missing.csv"])
{
WorkingDirectory = dir,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
ProcessTextOutput result = await Process.RunAndCaptureTextAsync(tar);
Console.WriteLine($"RunAndCaptureTextAsync: exit={result.ExitStatus.ExitCode} canceled={result.ExitStatus.Canceled}");
Console.Write($"stderr: {result.StandardError}");
ProcessExitStatus version = await Process.RunAsync("tar", ["--version"], silent: true);
Console.WriteLine($"RunAsync(silent): exit={version.ExitCode}");
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(1));
ProcessExitStatus slow = await Process.RunAsync("sleep", ["30"], cancellationToken: cts.Token);
Console.WriteLine($"sleep 30 with 1 s token: exit={slow.ExitCode} canceled={slow.Canceled} signal={slow.Signal}");
using Process sleeper = Process.Start("sleep", "30");
sleeper.Signal(PosixSignal.SIGTERM);
ProcessExitStatus status = await sleeper.WaitForExitStatusAsync();
Console.WriteLine($"SIGTERM: exit={status.ExitCode} signal={status.Signal}");
if (OperatingSystem.IsWindows() || OperatingSystem.IsLinux())
{
ProcessStartInfo child = new("sleep", ["1"]) { KillOnParentExit = true };
using Process process = Process.Start(child)!;
await process.WaitForExitAsync();
Console.WriteLine($"KillOnParentExit: child exit={process.ExitCode}");
}
RunAndCaptureTextAsync: exit=2 canceled=False
stderr: tar: missing.csv: Cannot stat: No such file or directory
tar: Exiting with failure status due to previous errors
RunAsync(silent): exit=0
sleep 30 with 1 s token: exit=137 canceled=True signal=SIGKILL
SIGTERM: exit=143 signal=SIGTERM
KillOnParentExit: child exit=0
RunAndCaptureTextAsync iki pipe'ı birlikte okur ve sonucu ProcessTextOutput olarak döner; deadlock riski artık API'nin içinde çözülmüş. İlk denememde Redirect* property'lerini vermemiştim ve method InvalidOperationException fırlattı: ProcessStartInfo alan overload'da iki yönlendirmeyi de açman gerekiyor, dosya adı ve argüman alan overload ise bunu kendisi yapıyor. silent: true ile çalışan tar --version ekrana hiçbir şey yazmadı.
İptal davranışı önemli: token iptal edilince RunAsync exception fırlatmadı, child'ı SIGKILL (yakalanamayan, process'i hemen sonlandıran Unix sinyali) ile öldürüp Canceled=true olan bir sonuç döndü. Exit code 137 Unix geleneğidir (128 + sinyal numarası 9). ProcessExitStatus.Signal sayesinde "kendi isteğiyle 1 ile çıktı" ile "dışarıdan öldürüldü" ayrımını exit code'dan tahmin etmen gerekmiyor. Process.Signal da yeni: SIGTERM (process'ten düzgün kapanmasını isteyen sinyal) ile process'e düzgün kapanma şansı verebilirsin.
KillOnParentExit, parent process ölünce child'ın da öldürülmesini sağlar; gece job'ı çökerse arkasında yetim bir tar kalmaz. What's new sayfası bu property'yi "yalnız Windows" diye anıyor, ama GA (genel kullanıma açılan kararlı sürüm) paketindeki API linux ve android'i de destekleniyor olarak işaretliyor; platform kontrolü olmadan derlediğimde aldığım CA1416 uyarısı da "'ProcessStartInfo.KillOnParentExit' is supported on: 'android', 'linux', 'windows'" diyordu. Linux container'ında ayrıca denedim: parent çıktıktan sonra property açıkken sleep 30 kalmadı, kapalıyken kaldı. Bu işarete göre macOS desteklenmiyor. Bu yeni API'ler .NET 11 gerektirir; .NET 10 hedefleyen bir projede ExternalTool gibi kendi sarmalayıcını kullanmaya devam edersin.
Stopwatch ile Süre Ölçmek
Bir işlemin süresini DateTime.Now farkıyla ölçmek iki nedenle yanlış. Birincisi duvar saati değişebilir: NTP (Network Time Protocol; makinenin saatini ağdaki bir zaman sunucusuna göre düzelten protokol) senkronizasyonu ya da elle yapılan bir düzeltme saati ileri veya geri atlatır ve ölçtüğün süre negatif bile çıkabilir. İkincisi DateTime.Now her çağrıda zone dönüşümü yapar. Stopwatch ise monoton bir sayaç kullanır; yalnız ileri gider ve süre ölçmek için tasarlanmıştır.
Console.WriteLine($"Stopwatch.IsHighResolution: {Stopwatch.IsHighResolution}, Frequency: {Stopwatch.Frequency:N0} ticks/s");
Stopwatch stopwatch = Stopwatch.StartNew();
await Task.Delay(200);
stopwatch.Stop();
Console.WriteLine($"Stopwatch : {stopwatch.Elapsed.TotalMilliseconds:F1} ms");
long started = Stopwatch.GetTimestamp();
await Task.Delay(200);
TimeSpan elapsed = Stopwatch.GetElapsedTime(started);
Console.WriteLine($"GetElapsedTime : {elapsed.TotalMilliseconds:F1} ms");
stopwatch.Restart();
await Task.Delay(50);
TimeSpan lap = stopwatch.Elapsed;
await Task.Delay(50);
Console.WriteLine($"Lap / total : {lap.TotalMilliseconds:F0} ms / {stopwatch.Elapsed.TotalMilliseconds:F0} ms (still running: {stopwatch.IsRunning})");
Stopwatch.IsHighResolution: True, Frequency: 1,000,000,000 ticks/s
Stopwatch : 211.7 ms
GetElapsedTime : 205.8 ms
Lap / total : 57 ms / 109 ms (still running: True)
Süreler makineye göre değişir; 200 ms'lik Task.Delay'in 205–212 ms sürmesi, timer çözünürlüğü ve thread pool zamanlaması yüzünden normal. Stopwatch.Frequency Linux'ta saniyede 1 milyar tick; Windows'ta farklı bir değerdir, bu yüzden ElapsedTicks'i TimeSpan.Ticks ile karıştırma (biri Stopwatch tick'i, diğeri 100 ns). Her zaman Elapsed ya da GetElapsedTime kullan.
Stopwatch bir class, dolayısıyla her StartNew bir allocation. .NET 7 ile gelen Stopwatch.GetTimestamp ve GetElapsedTime ikilisi ise yalnız bir long ile çalışır:
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 1_000; i++)
{
Stopwatch stopwatch = Stopwatch.StartNew();
stopwatch.Stop();
}
long withStopwatch = GC.GetAllocatedBytesForCurrentThread() - before;
before = GC.GetAllocatedBytesForCurrentThread();
TimeSpan total = TimeSpan.Zero;
for (int i = 0; i < 1_000; i++)
{
long started = Stopwatch.GetTimestamp();
total += Stopwatch.GetElapsedTime(started);
}
long withTimestamp = GC.GetAllocatedBytesForCurrentThread() - before;
1,000 x Stopwatch.StartNew() : 40,000 bytes allocated
1,000 x GetTimestamp/GetElapsedTime: 0 bytes allocated
Her Stopwatch 40 byte. Tek bir ölçüm için önemsiz, ama her HTTP isteğinde ya da her mesajda süre ölçen bir middleware'de bu sıfıra inebilir. Bir kodun gerçekten ne kadar hızlı olduğunu karşılaştırmak ise Stopwatch'ın işi değil: JIT (Just-In-Time; kodu çalışırken makine koduna çeviren derleyici), warm-up ve GC etkilerini ayıklamak için BenchmarkDotNet gerekir; Performance ve Load Testing serisinin BenchmarkDotNet yazısı bunu anlatıyor.
Bookstore: Gece Raporu
Parçaları birleştirelim. Gece raporu aracı klasörü ortam değişkeninden alıyor, CSV'yi yazıyor, tar ile arşivliyor ve her adımı süresiyle log'luyor:
string reportDir = Environment.GetEnvironmentVariable("BOOKSTORE_REPORT_DIR")
?? Path.Combine(Path.GetTempPath(), "bookstore-reports");
Directory.CreateDirectory(reportDir);
string day = "2026-11-14";
long started = Stopwatch.GetTimestamp();
string csvName = $"orders-{day}.csv";
await File.WriteAllLinesAsync(Path.Combine(reportDir, csvName),
[
"OrderNumber,Total",
.. Enumerable.Range(1001, 250).Select(n => string.Create(CultureInfo.InvariantCulture, $"BK-{n},{n % 7 * 50 + 99.90m}")),
]);
logger.LogInformation("Wrote {File} in {ElapsedMs:F1} ms", csvName, Stopwatch.GetElapsedTime(started).TotalMilliseconds);
string archive = $"nightly-{day}.tar.gz";
ToolResult result = await ExternalTool.RunAsync("tar", ["-czf", archive, csvName], TimeSpan.FromSeconds(30), reportDir);
if (result.TimedOut || result.ExitCode != 0)
{
logger.LogError("tar failed: exit={ExitCode} timedOut={TimedOut} stderr={StdErr}", result.ExitCode, result.TimedOut, result.StandardError.Trim());
return;
}
long size = new FileInfo(Path.Combine(reportDir, archive)).Length;
logger.LogInformation("Archived {Archive} ({Size} bytes), tar exit={ExitCode} in {ElapsedMs:F1} ms", archive, size, result.ExitCode, result.Elapsed.TotalMilliseconds);
logger.LogInformation("Nightly report finished in {TotalMs:F1} ms", Stopwatch.GetElapsedTime(started).TotalMilliseconds);
info: Bookstore.NightlyReport[0] Wrote orders-2026-11-14.csv in 44.2 ms
info: Bookstore.NightlyReport[0] Archived nightly-2026-11-14.tar.gz (791 bytes), tar exit=0 in 55.0 ms
info: Bookstore.NightlyReport[0] Nightly report finished in 111.8 ms
Log'da süreyi ayrı bir alan (ElapsedMs) olarak yazmak, mesaj metnine gömmekten daha kullanışlı: log sisteminde "tar 5 saniyeden uzun sürdü mü?" diye sorgulayabilirsin. Hata durumunda exit code, timeout bilgisi ve stderr birlikte log'lanıyor; sabah log'a bakan kişi aracı yeniden çalıştırmadan sorunu görebilir. Aracın kendisinin hangi exit code ile çıkacağı ise bir sonraki yazının konusu.
Üretimde Dikkat
Harici bir aracı çağırmak, uygulamanın dağıtımına bir bağımlılık ekler. Container image'ında araç yoksa (chiseled ya da distroless image'larda tar ve sh genelde yoktur) Process.Start Win32Exception fırlatır; adı Windows'a ait görünse de Linux'ta da bu tip kullanılır. Aracı açılışta bir kez çalıştırıp (örneğin --version) var olduğunu doğrulamak, gece yarısı öğrenmekten iyidir. Aynı işi .NET içinde yapan bir API varsa onu tercih et: arşiv için System.Formats.Tar ve System.IO.Compression var; .NET'te I/O İşlemleri serisinin "Compression ve Arşivler" yazısı ikisini de anlatıyor.
Her dış çağrıya bir timeout koy ve timeout'ta process ağacını öldür. Çıktıyı log'lamadan önce boyutunu sınırla; 1 MB'lık bir stderr'i log sistemine tek satır olarak göndermek başka bir sorun yaratır. Ortam değişkenlerinden okunan ayarları ve platform bilgisini uygulama açılırken bir kez log'la; hangi container'da hangi ayarla çalışıldığını sonradan bulmak zordur. Gizli değerleri (connection string, token) ise log'a yazma.
Süre ölçümlerini metric olarak da yayımlamak istiyorsan System.Diagnostics.Metrics ile bir histogram kullan; tek tek log satırlarından yüzdelik hesaplamak pahalıdır. OpenTelemetry tarafı Observability serisinde anlatılıyor.
Sık Düşülen Tuzaklar
stdout ve stderr'i aynı anda okumadan deadlock. Birini sonuna kadar okuyup diğerine sonra bakmak, child çok yazdığında iki process'i birbirine kilitler. İki pipe'ı birlikte oku ya da .NET 11'de RunAndCaptureTextAsync kullan.
Argümanları tek string'de birleştirmek. Boşluklu dosya adları bölünür, kullanıcı verisi yeni bir seçenek ya da shell üzerinden yeni bir komut olabilir. ArgumentList kullan, shell'den kaçın.
UseShellExecute farklarını bilmemek. .NET Framework'ten taşınan kod varsayılanın true'dan false'a döndüğünü bilmezse dosya açma çağrıları Win32Exception verir; tersine true verilen bir çağrıda çıktı yönlendirilemez.
Exit code'u kontrol etmemek. Araç hata verse de Process.Start başarılıdır; sonucu yalnız ExitCode söyler.
Süreyi DateTime.Now farkıyla ölçmek. Saat düzeltmeleri ölçümü bozar; Stopwatch ya da GetTimestamp/GetElapsedTime kullan.
Linux'ta ortam değişkeni adının harf duyarlılığını unutmak ve GetFolderPath'in boş dönebileceğini hesaba katmamak.
Küçük Bir Deneme
★☆☆ env-info örneğini --cpus 2 ile başlatılmış bir container'da ve kendi makinende çalıştır; ProcessorCount değerlerini karşılaştır.
★★☆ ExternalTool'a bir maxOutputChars parametresi ekle: stdout ve stderr'in yalnız ilk N karakterini sakla, kesildiyse sonuçta bunu belirt. proc-noisy ile dene.
★★★ Gece raporu aracındaki tar çağrısını System.Formats.Tar ve GZipStream ile değiştir. İki sürümü aynı CSV ile çalıştır, arşivlerin içeriğini tar -tzf ile karşılaştır ve harici araç bağımlılığının kalktığını container image'ında doğrula.
Gece raporu aracı artık işini yapıyor, ama hâlâ args'ı elle okuyan ve her durumda 0 ile çıkan bir konsol uygulaması. Bir sonraki yazıda onu bookstore-admin import-prices prices.csv --dry-run gibi alt komutları, yardım metni, doğru exit code'ları ve Ctrl+C desteği olan gerçek bir CLI aracına çeviriyoruz.