Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix output stuttering using a ticket lock #10653

Merged
2 commits merged into from
Jul 14, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 6 additions & 11 deletions src/cascadia/PublicTerminalCore/HwndTerminal.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,6 @@ HwndTerminal::HwndTerminal(HWND parentHwnd) :
_desiredFont{ L"Consolas", 0, DEFAULT_FONT_WEIGHT, { 0, 14 }, CP_UTF8 },
_actualFont{ L"Consolas", 0, DEFAULT_FONT_WEIGHT, { 0, 14 }, CP_UTF8, false },
_uiaProvider{ nullptr },
_uiaProviderInitialized{ false },
_currentDpi{ USER_DEFAULT_SCREEN_DPI },
_pfnWriteCallback{ nullptr },
_multiClickTime{ 500 } // this will be overwritten by the windows system double-click time
Expand Down Expand Up @@ -324,26 +323,22 @@ void HwndTerminal::_UpdateFont(int newDpi)

IRawElementProviderSimple* HwndTerminal::_GetUiaProvider() noexcept
{
if (nullptr == _uiaProvider && !_uiaProviderInitialized)
// If TermControlUiaProvider throws during construction,
// we don't want to try constructing an instance again and again.
// _uiaProviderInitialized helps us prevent this.
if (!_uiaProviderInitialized)
{
std::unique_lock<std::shared_mutex> lock;
try
{
#pragma warning(suppress : 26441) // The lock is named, this appears to be a false positive
lock = _terminal->LockForWriting();
if (_uiaProviderInitialized)
{
return _uiaProvider.Get();
}

auto lock = _terminal->LockForWriting();
LOG_IF_FAILED(::Microsoft::WRL::MakeAndInitialize<::Microsoft::Terminal::TermControlUiaProvider>(&_uiaProvider, this->GetUiaData(), this));
DHowett marked this conversation as resolved.
Show resolved Hide resolved
_uiaProviderInitialized = true;
}
catch (...)
{
LOG_HR(wil::ResultFromCaughtException());
_uiaProvider = nullptr;
}
_uiaProviderInitialized = true;
}

return _uiaProvider.Get();
Expand Down
2 changes: 1 addition & 1 deletion src/cascadia/PublicTerminalCore/HwndTerminal.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ struct HwndTerminal : ::Microsoft::Console::Types::IControlAccessibilityInfo
FontInfoDesired _desiredFont;
FontInfo _actualFont;
int _currentDpi;
bool _uiaProviderInitialized;
std::function<void(wchar_t*)> _pfnWriteCallback;
::Microsoft::WRL::ComPtr<::Microsoft::Terminal::TermControlUiaProvider> _uiaProvider;

Expand All @@ -83,6 +82,7 @@ struct HwndTerminal : ::Microsoft::Console::Types::IControlAccessibilityInfo
std::unique_ptr<::Microsoft::Console::Render::DxEngine> _renderEngine;

bool _focused{ false };
bool _uiaProviderInitialized{ false };

std::chrono::milliseconds _multiClickTime;
unsigned int _multiClickCounter{};
Expand Down
8 changes: 4 additions & 4 deletions src/cascadia/TerminalCore/Terminal.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -866,19 +866,19 @@ WORD Terminal::_TakeVirtualKeyFromLastKeyEvent(const WORD scanCode) noexcept
// Return Value:
// - a shared_lock which can be used to unlock the terminal. The shared_lock
// will release this lock when it's destructed.
[[nodiscard]] std::shared_lock<std::shared_mutex> Terminal::LockForReading()
[[nodiscard]] std::unique_lock<til::ticket_lock> Terminal::LockForReading()
{
return std::shared_lock<std::shared_mutex>(_readWriteLock);
return std::unique_lock{ _readWriteLock };
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there's no difference any more between Locking for Writing and Reading thanks to the mechanics of the ticket lock... wouldn't it make sense to have one call the other to make that super clear until the future revision where you build that more complicated fair read/write lock for funsies?

}

// Method Description:
// - Acquire a write lock on the terminal.
// Return Value:
// - a unique_lock which can be used to unlock the terminal. The unique_lock
// will release this lock when it's destructed.
[[nodiscard]] std::unique_lock<std::shared_mutex> Terminal::LockForWriting()
[[nodiscard]] std::unique_lock<til::ticket_lock> Terminal::LockForWriting()
{
return std::unique_lock<std::shared_mutex>(_readWriteLock);
return std::unique_lock{ _readWriteLock };
}

Viewport Terminal::_GetMutableViewport() const noexcept
Expand Down
17 changes: 13 additions & 4 deletions src/cascadia/TerminalCore/Terminal.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
#include "../../cascadia/terminalcore/ITerminalApi.hpp"
#include "../../cascadia/terminalcore/ITerminalInput.hpp"

#include <til/ticket_lock.h>

static constexpr std::wstring_view linkPattern{ LR"(\b(https?|ftp|file)://[-A-Za-z0-9+&@#/%?=~_|$!:,.;]*[A-Za-z0-9+&@#/%=~_|$])" };
static constexpr size_t TaskbarMinProgress{ 10 };

Expand Down Expand Up @@ -77,8 +79,8 @@ class Microsoft::Terminal::Core::Terminal final :
// WritePastedText goes directly to the connection
void WritePastedText(std::wstring_view stringView);

[[nodiscard]] std::shared_lock<std::shared_mutex> LockForReading();
[[nodiscard]] std::unique_lock<std::shared_mutex> LockForWriting();
[[nodiscard]] std::unique_lock<til::ticket_lock> LockForReading();
[[nodiscard]] std::unique_lock<til::ticket_lock> LockForWriting();

short GetBufferHeight() const noexcept;

Expand Down Expand Up @@ -244,6 +246,15 @@ class Microsoft::Terminal::Core::Terminal final :
std::function<void()> _pfnWarningBell;
std::function<void(std::wstring_view)> _pfnTitleChanged;
std::function<void(std::wstring_view)> _pfnCopyToClipboard;

// I've specifically put this instance here as it requires
// alignas(std::hardware_destructive_interference_size)
// for best performance.
//
// But we can abuse the fact that the surrounding members rarely change and are huge
// (std::function is like 64 bytes) to create some natural padding without wasting space.
til::ticket_lock _readWriteLock;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you add the alignas now, would that make it safer (in case somebody changes the order) and not compromise the layout by adding wasted padding space?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ask because... this is a micro-optimization somebody could easily accidentally remove or not fully understand and fail to maintain.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alignas would add 56 bytes of padding space and force _readWriteLock to be 64 bytes large.
I had hoped that the comment I added there would be enough to prevent others from accidentally changing the order. It someone would move it to be next to the later members in this class it would double the overhead of the ticket lock (from 0.03% to 0.06% CPU usage), but I think the comment should be an okay-ish reminder to not do that. I hope?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the comment is OK. I wonder if we'd ever be possessed to remove all the std::functions though and then the trick wouldn't work anymore.


std::function<void(const int, const int, const int)> _pfnScrollPositionChanged;
std::function<void(const til::color)> _pfnBackgroundColorChanged;
std::function<void()> _pfnCursorPositionChanged;
Expand Down Expand Up @@ -299,8 +310,6 @@ class Microsoft::Terminal::Core::Terminal final :
SelectionExpansionMode _multiClickSelectionMode;
#pragma endregion

std::shared_mutex _readWriteLock;

// TODO: These members are not shared by an alt-buffer. They should be
// encapsulated, such that a Terminal can have both a main and alt buffer.
std::unique_ptr<TextBuffer> _buffer;
Expand Down
4 changes: 2 additions & 2 deletions src/cascadia/TerminalCore/terminalrenderdata.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -230,14 +230,14 @@ catch (...)
// they're done with any querying they need to do.
void Terminal::LockConsole() noexcept
{
_readWriteLock.lock_shared();
_readWriteLock.lock();
}

// Method Description:
// - Unlocks the terminal after a call to Terminal::LockConsole.
void Terminal::UnlockConsole() noexcept
{
_readWriteLock.unlock_shared();
_readWriteLock.unlock();
}

// Method Description:
Expand Down
29 changes: 29 additions & 0 deletions src/inc/til/atomic.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

#pragma once

namespace til
{
template<typename T>
inline void atomic_wait(const std::atomic<T>& atomic, const T& current, DWORD waitMilliseconds = INFINITE) noexcept
{
static_assert(sizeof(atomic) == sizeof(current));
#pragma warning(suppress : 26492) // Don't use const_cast to cast away const or volatile
WaitOnAddress(const_cast<std::atomic<T>*>(&atomic), const_cast<T*>(&current), sizeof(current), waitMilliseconds);
}

template<typename T>
inline void atomic_notify_one(const std::atomic<T>& atomic) noexcept
{
#pragma warning(suppress : 26492) // Don't use const_cast to cast away const or volatile
WakeByAddressSingle(const_cast<std::atomic<T>*>(&atomic));
}

template<typename T>
inline void atomic_notify_all(const std::atomic<T>& atomic) noexcept
{
#pragma warning(suppress : 26492) // Don't use const_cast to cast away const or volatile
WakeByAddressAll(const_cast<std::atomic<T>*>(&atomic));
}
}
56 changes: 56 additions & 0 deletions src/inc/til/ticket_lock.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

#pragma once

#include "atomic.h"

namespace til
{
// ticket_lock implements a classic fair lock.
//
// Compared to a SRWLOCK this implementation is significantly more unsafe to use:
// Forgetting to call unlock or calling unlock more than once, will lead to deadlocks,
// as _now_serving will remain out of sync with _next_ticket and prevent any further lockings.
//
// I recommend to use the following with this class:
// * A low number of concurrent accesses (this lock doesn't scale well beyond 2 threads)
// * alignas(std::hardware_destructive_interference_size) to prevent false sharing
// * std::unique_lock or std::scoped_lock to prevent unbalanced lock/unlock calls
struct ticket_lock
{
void lock() noexcept
{
const auto ticket = _next_ticket.fetch_add(1, std::memory_order_relaxed);

for (;;)
{
const auto current = _now_serving.load(std::memory_order_acquire);
if (current == ticket)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is provably no case where now_serving will jump multiple steps above the expected ticket value, correct?

Copy link
Member Author

@lhecker lhecker Jul 14, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is possible, if someone where to call unlock() multiple times.
But if you don't, it works exactly like the Wikipedia article describes the algorithm.

{
break;
}

til::atomic_wait(_now_serving, current);
}
}

void unlock() noexcept
{
_now_serving.fetch_add(1, std::memory_order_release);
til::atomic_notify_all(_now_serving);
}

private:
// You may be inclined to add alignas(std::hardware_destructive_interference_size)
// here to force the two atomics on separate cache lines, but I suggest to carefully
// benchmark such a change. Since this ticket_lock is primarily used to synchronize
// exactly 2 threads, it actually helps us that these atomic are on the same cache line
// as any change by one thread is flushed to the other, which will then read it anyways.
//
// Integer overflow doesn't break the algorithm, as these two
// atomics are treated more like "IDs" and less like counters.
std::atomic<uint32_t> _next_ticket{ 0 };
lhecker marked this conversation as resolved.
Show resolved Hide resolved
std::atomic<uint32_t> _now_serving{ 0 };
};
}