This answer is pretty complete and explains the "issue"/subject very well.
But to extend it a little, your example with the async void "works" for one reason: For Debug.Log the synchronization context doesn't matter. You can safely Debug.Log from different threads and background tasks and Unity handles them in the console. BUT as soon as you would try to use anything from the Unity API that is only allowed on the main thread (basically everything that immediately depends on or changes the scene content) it might break since it is not guaranteed that await ends up on the main thread.
However, what now?
Nothing really speaks against using Thread and Task.Run in Unity!
You only have to make sure to dispatch any results back into the Unity main thread.
So I just wanted to give some actual examples of how this can be done.
Something often used is a so called "Main thread dispatcher" .. basically just a ConcurrentQueue which allows you to Enqueue callback Actions from just any thread and then TryDequeue and invoke these in an Update routine on the Unity main thread.
This looks somewhat like e.g.
/// <summary>
/// A simple dispatcher behaviour for passing any multi-threading action into the next Update call in the Unity main thread
/// </summary>
public class MainThreadDispatcher : MonoBehaviour
{
    /// <summary>
    /// The currently existing instance of this class (singleton)
    /// </summary>
    private static MainThreadDispatcher _instance;
    /// <summary>
    /// A thread-safe queue (first-in, first-out) for dispatching actions from multiple threads into the Unity main thread
    /// </summary>
    private readonly ConcurrentQueue<Action> actions = new ConcurrentQueue<Action>();
    /// <summary>
    /// Public read-only property to access the instance
    /// </summary>
    public static MainThreadDispatcher Instance => _instance;
    private void Awake ()
    {
        // Ensure singleton 
        if(_instance && _instance != this)
        {
            Destroy (gameObject);
            return;
        }
        _instance = this;
        // Keep when the scene changes 
        // sometimes you might not want that though
        DontDestroyOnLoad (gameObject);
    }
    private void Update ()
    {
        // In the main thread work through all dispatched callbacks and invoke them
        while(actions.TryDequeue(out var action))
        {
            action?.Invoke();
        }
    }
    /// <summary>
    /// Dispatch an action into the next <see cref="Update"/> call in the Unity main thread
    /// </summary>
    /// <param name="action">The action to execute in the next <see cref="Update"/> call</param>
    public void DoInNextUpdate(Action action)
    {
        // Simply append the action thread-safe so it is scheduled for the next Update call
        actions.Enqueue(action);
    }
}
Of course you need this attached to an object in your scene, then from anywhere you could use e.g.
public void DoSomethingAsync()
{
    // Run SomethingWrapper async and pass in a callback what to do with the result
    Task.Run(async () => await SomethingWrapper(result => 
    { 
        // since the SomethingWrapper forwards this callback to the MainThreadDispatcher
        // this will be executed on the Unity main thread in the next Update frame
        new GameObject(result.ToString()); 
    }));
}
private async Task<int> Something()
{
    await Task.Delay(3000);
    return 42;
}
private async Task SomethingWrapper (Action<int> handleResult)
{
    // cleanly await the result
    var number = await Something ();
    // Then dispatch given callback into the main thread
    MainThreadDispatcher.Instance.DoInNextUpdate(() =>
    {
        handleResult?.Invoke(number);
    });
}
This makes sense if you have a lot of asynchronous stuff going on and want to dispatch them all at some point back to the main thread.

Another possibility is using Coroutines. A Coroutine is basically a bit like a temporary Update method (the MoveNext of the IEnumerator is called once a frame by default) so you can just repeatedly check if your task is done already on the main thread. This is what Unity uses themselves e.g. for UnityWebRequest and could looked somewhat like
public void DoSomethingAsync()
{
    // For this this needs to be a MonoBehaviour of course
    StartCorouine (SomethingRoutine ());
}
private IEnumerator SomethingRoutine()
{
    // Start the task async but don't wait for the result here
    var task = Task.Run(Something);
    // Rather wait this way
    // Basically each frame check if the task is still runnning
    while(!task.IsCompleted)
    {
        // Use the default yield which basically "pauses" the routine here
        // allows Unity to execute the rest of the frame
        // and continues from here in the next frame
        yield return null;
    }
    // Safety check if the task actually finished or is canceled or faulty
    if(task.IsCompletedSuccessfully)
    {
        // Since we already waited until the task is finished 
        // This time Result will not freeze the app
        var number = task.Result;
        new GameObject (number.ToString());
    }
    else
    {
        Debug.LogWarning("Task failed or canceled");
    }
}
private async Task<int> Something ()
{
    await Task.Delay(3000);
    return 42;
}
