OutOfMemory и GDI+ иногда совсем не OutOfMemory
При выполнении последнего проекта на работе мы с коллегой столкнулись с тем, что некоторые методы и конструкторы в System.Drawing падают с OutOfMemory в совершенно обычных местах и когда памяти свободной ещё очень и очень много.
Суть проблемы
Для примера возьмём этот код на C#:
using System.Drawing;
using System.Drawing.Drawing2D;
namespace TempProject {
static class Program {
static void Main() {
var point1 = new PointF(-3.367667E-16f, 0f);
var point2 = new PointF(3.367667E-16f, 100f);
var brush = new LinearGradientBrush(point1, point2, Color.White, Color.Black);
}
}
}
При выполнении последней строчки гарантировано будет выброшено исключение OutOfMemoryException, независимо от того, сколько свободной памяти имеется. Причём, если заменить 3.367667E-16f и -3.367667E-16f на 0, что очень близко к правде, всё будет работать нормально — заливка будет создана. На мой взгляд, такое поведение выглядит странным. Давайте разберёмся, от чего это происходит и как с этим бороться.
Выясняем причины болезни
Начнём с того, что узнаем, что происходит в конструкторе LinearGradientBrush. Для этого можно заглянуть на referencesource.microsoft.com. Там будет следующее:
public LinearGradientBrush(PointF point1, PointF point2, Color color1, Color color2) {
IntPtr brush = IntPtr.Zero;
int status = SafeNativeMethods.Gdip.GdipCreateLineBrush(
new GPPOINTF(point1),
new GPPOINTF(point2),
color1.ToArgb(),
color2.ToArgb(),
(int)WrapMode.Tile,
out brush
);
if (status != SafeNativeMethods.Gdip.Ok)
throw SafeNativeMethods.Gdip.StatusException(status);
SetNativeBrushInternal(brush);
}
Несложно заметить, что самое главное тут — вызов GDI+ метода GdipCreateLineBrush. Значит, необходимо смотреть что происходит внутри него. Для этого воспользуемся IDA + HexRays. Загрузим в IDA gdiplus.dll. Если надо определить, какую именно версию библиотеки отлаживать, то можно воспользоваться Process Explorer от SysInternals. Кроме того, могут возникнуть проблемы с правами на папку, где лежит gdiplus.dll. Они решаются сменой владельца этой папки.
Итак, откроем gdiplus.dll в IDA. Дождёмся обработки файла. После этого выберем в меню: View → Open Subviews → Exports, чтобы открыть все функции, которые экспортируются из этой библиотеки, и найдём там GdipCreateLineBrush.
Благодаря загрузке символов, мощности HexRays и документации можно без труда перевести код метода из ассемблера в читабельный код на С++:
GpStatus __userpurge GdipCreateLineBrush@(int a1@, GpPointF *point1, GpPointF *point2, int color1, int color2, int wrapMode, GpRectGradient **result)
{
GpStatus status; // esi MAPDST
GpGradientBrush *v8; // eax
GpRectGradient *v9; // eax
int v12; // [esp+4h] [ebp-Ch]
int vColor1; // [esp+8h] [ebp-8h]
int vColor2; // [esp+Ch] [ebp-4h]
FPUStateSaver::FPUStateSaver(&v12, 1);
EnterCriticalSection(&GdiplusStartupCriticalSection::critSec);
if ( Globals::LibraryInitRefCount > 0 )
{
LeaveCriticalSection(&GdiplusStartupCriticalSection::critSec);
if ( result && point1 && point2 && wrapMode != 4 )
{
vColor1 = color1;
vColor2 = color2;
v8 = operator new(a1);
status = 0;
if ( v8 )
v9 = GpLineGradient::GpLineGradient(v8, point1, point2, &vColor1, &vColor2, wrapMode);
else
v9 = 0;
*result = v9;
if ( !CheckValid(result) )
status = OutOfMemory;
}
else
{
status = InvalidParameter;
}
}
else
{
LeaveCriticalSection(&GdiplusStartupCriticalSection::critSec);
status = GdiplusNotInitialized;
}
__asm { fclex }
return status;
}
Код этого метода абсолютно понятен. Его суть заключена в строках:
if ( result && point1 && point2 && wrapMode != 4 )
{
vColor1 = color1;
vColor2 = color2;
v8 = operator new(a1);
status = 0;
if ( v8 )
v9 = GpLineGradient::GpLineGradient(v8, point1, point2, &vColor1, &vColor2, wrapMode);
else
v9 = 0;
*result = v9;
if ( !CheckValid(result) )
status = OutOfMemory
}
else {
status = InvalidParameter;
}
GdiPlus проверяет, верны ли входные параметры и, если это не так, то возвращает InvalidParameter. В противном же случае создаётся GpLineGradient и проверяется на валидность. Если валидация не пройдена, то возвращается OutOfMemory. Видимо, это наш случай, а, значит, надо разобраться, что происходит внутри конструктора GpLineGradient:
GpRectGradient *__thiscall GpLineGradient::GpLineGradient(GpGradientBrush *this, GpPointF *point1, GpPointF *point2, int color1, int color2, int wrapMode)
{
GpGradientBrush *v6; // esi
float height; // ST2C_4
double v8; // st7
float width; // ST2C_4
float angle; // ST2C_4
GpRectF rect; // [esp+1Ch] [ebp-10h]
v6 = this;
GpGradientBrush::GpGradientBrush(this);
GpRectGradient::DefaultBrush(v6);
rect.Height = 0.0;
rect.Width = 0.0;
rect.Y = 0.0;
rect.X = 0.0;
*v6 = &GpLineGradient::`vftable;
if ( LinearGradientRectFromPoints(point1, point2, &rect) )
{
*(v6 + 1) = 1279869254;
}
else
{
height = point2->Y - point1->Y;
v8 = height;
width = point2->X - point1->X;
angle = atan2(v8, width) * 180.0 / 3.141592653589793;
GpLineGradient::SetLineGradient(v6, point1, point2, &rect, color1, color2, angle, 0, wrapMode);
}
return v6;
}
Здесь происходит инициализация переменных, которые потом заполняются в LinearGradientRectFromPoints и SetLineGradient. Смею предположить, что rect — это прямоугольник заливки, основанный на point1 и point2, чтобы убедиться в этом можно заглянуть в LinearGradientRectFromPoints:
GpStatus __fastcall LinearGradientRectFromPoints(GpPointF *p1, GpPointF *p2, GpRectF *result)
{
double vP1X; // st7
float vLeft; // ST1C_4 MAPDST
double vP1Y; // st7
float vTop; // ST1C_4 MAPDST
float vWidth; // ST18_4 MAPDST
double vWidth3; // st7
float vHeight; // ST18_4 MAPDST
float vP2X; // [esp+18h] [ebp-8h]
float vP2Y; // [esp+1Ch] [ebp-4h]
if ( IsClosePointF(p1, p2) )
return InvalidParameter;
vP2X = p2->X;
vP1X = p1->X;
if ( vP2X <= vP1X )
vP1X = vP2X;
vLeft = vP1X;
result->X = vLeft;
vP2Y = p2->Y;
vP1Y = p1->Y;
if ( vP2Y <= vP1Y )
vP1Y = vP2Y;
vTop = vP1Y;
result->Y = vTop;
vWidth = p1->X - p2->X;
vWidth = fabs(vWidth);
vWidth3 = vWidth;
result->Width = vWidth;
vHeight = p1->Y - p2->Y;
vHeight = fabs(vHeight);
result->Height = vHeight;
vWidth = vWidth3;
if ( IsCloseReal(p1->X, p2->X) )
{
result->X = vLeft - 0.5 * vHeight;
result->Width = vHeight;
vWidth = vHeight;
}
if ( IsCloseReal(p1->Y, p2->Y) )
{
result->Y = vTop - vWidth * 0.5;
result->Height = vWidth;
}
return 0;
}
Как и предполагалось, rect — прямоугольник из точек point1 и point2.
Теперь вернёмся к нашей основной проблеме и разберёмся что происходит внутри SetLineGradient:
GpStatus __thiscall GpLineGradient::SetLineGradient(DpGradientBrush *this, GpPointF *p1, GpPointF *p2, GpRectF *rect, int color1, int color2, float angle, int zero, int wrapMode)
{
_DWORD *v10; // edi
float *v11; // edi
GpStatus v12; // esi
_DWORD *v14; // edi
this->wrapMode = wrapMode;
v10 = &this->dword40;
this->Color1 = *color1;
this->Color2 = *color2;
this->Color11 = *color1;
this->Color21 = *color2;
this->dwordB0 = 0;
this->float98 = 1.0;
this->dwordA4 = 1;
this->dwordA0 = 1;
this->float94 = 1.0;
this->dwordAC = 0;
if ( CalcLinearGradientXform(zero, rect, angle, &this->gap4[16]) )
{
*this->gap4 = 1279869254;
*v10 = 0;
v14 = v10 + 1;
*v14 = 0;
++v14;
*v14 = 0;
v14[1] = 0;
*&this[1].gap4[12] = 0;
*&this[1].gap4[16] = 0;
*&this[1].gap4[20] = 0;
*&this[1].gap4[24] = 0;
*&this->gap44[28] = 0;
v12 = InvalidParameter;
}
else
{
*this->gap4 = 1970422321;
*v10 = LODWORD(rect->X);
v11 = (v10 + 1);
*v11 = rect->Y;
++v11;
*v11 = rect->Width;
v11[1] = rect->Height;
*&this->gap44[28] = zero;
v12 = 0;
*&this[1].gap4[12] = *p1;
*&this[1].gap4[20] = *p2;
}
return v12;
}
В SetLineGradient тоже происходит только инициализация полей. So, we need to go deeper:
int __fastcall CalcLinearGradientXform(int zero, GpRectF *rect, float angle, int a4)
{
//...
//...
//...
return GpMatrix::InferAffineMatrix(a4, points, rect) != OK ? InvalidParameter : OK;
}
И, наконец:
GpStatus __thiscall GpMatrix::InferAffineMatrix(int this, GpPointF *points, GpRectF *rect)
{
//...
double height; // st6
double y; // st5
double width; // st4
double x; // st3
double bottom; // st2
float right; // ST3C_4
float rectArea; // ST3C_4
//...
x = rect->X;
y = rect->Y;
width = rect->Width;
height = rect->Height;
right = x + width;
bottom = height + y;
rectArea = bottom * right - x * y - (y * width + x * height);
rectArea = fabs(rectArea);
if ( rectArea < 0.00000011920929 )
return InvalidParameter;
//...
}
В методе InferAffineMatrix происходит именно то, что нас интересует. Тут проверяется площадь rect — исходного прямоугольника из точек, и если она меньше, чем 0.00000011920929, то InferAffineMatrix возвращает InvalidParameter. 0.00000011920929 — это машинный эпсилон для float (FLT_EPSILON). Можно заметить, как интересно в Microsoft считают площадь прямоугольника:
rectArea = bottom * right - x * y - (y * width + x * height);
Из площади до правого нижнего угла вычитают площадь до верхнего левого, затем вычитают площадь над прямоугольником и слева от прямоугольника. Зачем так сделано, мне не понятно; надеюсь, когда-нибудь я познаю этот тайный метод.
Итак, что мы имеем:
- InnerAffineMatrix возвращает InvalidParameter;
- CalcLinearGradientXForm пробрасывает этот результат выше;
- В SetLineGradient выполнение пойдёт по ветке if и метод тоже вернёт InvalidParameter;
- Конструктор GpLineGradient потеряет информацию об InvalidParameter и вернёт неинициализированный до конца объект GpLineGradient — это очень плохо!
- GdipCreateLineBrush проверит в CheckValid (строка 26) на правильность объект GpLineGradient с незаполненными до конца полями и закономерно вернёт false.
- После этого status поменяется на OutOfMemory, что и получит .NET на выходе из GDI+ метода.
Выходит, что Microsoft зачем-то игнорирует возвращаемый статус некоторых методов, делает из-за этого неверные предположения и усложняет понимание работы библиотеки для других программистов. Но ведь всего-то надо было из конструктора GpLineGradient пробрасывать статус выше, а в GdipCreateLineBrush проверять возвращаемое значение на OK и в противном случае возвращать статус конструктора. Тогда для пользователей GDI+ сообщение об ошибке, произошедшей внутри библиотеки, выглядело бы более логичным.
Вариант с заменой очень маленьких чисел на ноль, т.е. с вертикальной заливкой, выполняется без ошибок из-за магии, которую Microsoft выполняет в методе LinearGradientRectFromPoints в строках с 35 по 45:
if ( IsCloseReal(p1->X, p2->X) )
{
result->X = vLeft - 0.5 * vHeight;
result->Width = vHeight;
vWidth = vHeight;
}
if ( IsCloseReal(p1->Y, p2->Y) )
{
result->Y = vTop - vWidth * 0.5;
result->Height = vWidth;
}
Как лечить?
Как же избежать этого падения в .NET коде? Самый простой и очевидный вариант — сравнить площадь прямоугольника из точек point1 и point2 с FLT_EPSILON и не создавать градиент, если площадь меньше. Но при таком варианте мы потеряем информацию о градиенте и нарисуется незакрашенная область, что нехорошо. Мне видится более приемлемым вариант, когда проверяется угол градиентной заливки и если выясняется, что заливка близка к горизонтальной или вертикальной, то выставляем одинаковыми соответствующие параметры у точек:
static LinearGradientBrush CreateBrushSafely(PointF p1, PointF p2) {
if(IsShouldNormalizePoints(p1, p2)) {
if(!NormalizePoints(ref p1, ref p2))
return null;
}
var brush = new LinearGradientBrush(p1, p2, Color.White, Color.Black);
return brush;
}
static bool IsShouldNormalizePoints(PointF p1, PointF p2) {
float width = Math.Abs(p1.X - p2.X);
float height = Math.Abs(p1.Y - p2.Y);
return width * height < FLT_EPSILON && !(IsCloseFloat(p1.X, p2.X) || IsCloseFloat(p1.Y, p2.Y));
}
static bool IsCloseFloat(float v1, float v2) {
var t = v2 == 0.0f ? 1.0f : v2;
return Math.Abs((v1 - v2) / t) < FLT_EPSILON;
}
static bool NormalizePoints(ref PointF p1, ref PointF p2) {
const double twoDegrees = 0.03490658503988659153847381536977d;
float width = Math.Abs(p1.X - p2.X);
float height = Math.Abs(p1.Y - p2.Y);
var angle = Math.Atan2(height, width);
if (Math.Abs(angle) < twoDegrees) {
p1.Y = p2.Y;
return true;
}
if (Math.Abs(angle - Math.PI / 2) < twoDegrees) {
p1.X = p2.X;
return true;
}
return false;
}
А как дела у конкурентов?
Давайте узнаем что происходит в Wine. Для этого посмотрим на исходный код Wine, строка 306:
/******************************************************************************
* GdipCreateLineBrush [GDIPLUS.@]
*/
GpStatus WINGDIPAPI GdipCreateLineBrush(GDIPCONST GpPointF* startpoint,
GDIPCONST GpPointF* endpoint, ARGB startcolor, ARGB endcolor,
GpWrapMode wrap, GpLineGradient **line)
{
TRACE("(%s, %s, %x, %x, %d, %p)\n", debugstr_pointf(startpoint),
debugstr_pointf(endpoint), startcolor, endcolor, wrap, line);
if(!line || !startpoint || !endpoint || wrap == WrapModeClamp)
return InvalidParameter;
if (startpoint->X == endpoint->X && startpoint->Y == endpoint->Y)
return OutOfMemory;
*line = heap_alloc_zero(sizeof(GpLineGradient));
if(!*line) return OutOfMemory;
(*line)->brush.bt = BrushTypeLinearGradient;
(*line)->startpoint.X = startpoint->X;
(*line)->startpoint.Y = startpoint->Y;
(*line)->endpoint.X = endpoint->X;
(*line)->endpoint.Y = endpoint->Y;
(*line)->startcolor = startcolor;
(*line)->endcolor = endcolor;
(*line)->wrap = wrap;
(*line)->gamma = FALSE;
(*line)->rect.X = (startpoint->X < endpoint->X ? startpoint->X: endpoint->X);
(*line)->rect.Y = (startpoint->Y < endpoint->Y ? startpoint->Y: endpoint->Y);
(*line)->rect.Width = fabs(startpoint->X - endpoint->X);
(*line)->rect.Height = fabs(startpoint->Y - endpoint->Y);
if ((*line)->rect.Width == 0)
{
(*line)->rect.X -= (*line)->rect.Height / 2.0f;
(*line)->rect.Width = (*line)->rect.Height;
}
else if ((*line)->rect.Height == 0)
{
(*line)->rect.Y -= (*line)->rect.Width / 2.0f;
(*line)->rect.Height = (*line)->rect.Width;
}
(*line)->blendcount = 1;
(*line)->blendfac = heap_alloc_zero(sizeof(REAL));
(*line)->blendpos = heap_alloc_zero(sizeof(REAL));
if (!(*line)->blendfac || !(*line)->blendpos)
{
heap_free((*line)->blendfac);
heap_free((*line)->blendpos);
heap_free(*line);
*line = NULL;
return OutOfMemory;
}
(*line)->blendfac[0] = 1.0f;
(*line)->blendpos[0] = 1.0f;
(*line)->pblendcolor = NULL;
(*line)->pblendpos = NULL;
(*line)->pblendcount = 0;
linegradient_init_transform(*line);
TRACE("<-- %p\n", *line);
return Ok;
}
Здесь есть единственная проверка параметров на валидность:
if(!line || !startpoint || !endpoint || wrap == WrapModeClamp)
return InvalidParameter;
Скорее всего следующее было написано для совместимости с Windows:
if (startpoint->X == endpoint->X && startpoint->Y == endpoint->Y)
return OutOfMemory;
А в остальном нет ничего интересного — выделение памяти и заполнение полей. Из исходного кода становится очевидно, что в Wine создание проблемной градиентной заливки должно выполняться без ошибок. И действительно — если запустить следующую программу в Windows (я запускал в Windows10×64)
#include
#include "stdafx.h"
#include
#include
#pragma comment(lib,"gdiplus.lib")
void CreateBrush(float x1, float x2) {
Gdiplus::LinearGradientBrush linGrBrush(
Gdiplus::PointF(x1, -0.5f),
Gdiplus::PointF(x2, 10.5f),
Gdiplus::Color(255, 0, 0, 0),
Gdiplus::Color(255, 255, 255, 255));
const int status = linGrBrush.GetLastStatus();
const char* result;
if (status == 3) {
result = "OutOfMemory";
}
else {
result = "Ok";
}
std::cout << result << "\n";
}
int main() {
Gdiplus::GdiplusStartupInput gdiplusStartupInput;
ULONG_PTR gdiplusToken;
Gdiplus::GdiplusStartup(&gdiplusToken, &gdiplusStartupInput, NULL);
Gdiplus::Graphics myGraphics(GetDC(0));
CreateBrush(-3.367667E-16f, 3.367667E-16f);
CreateBrush(0, 0);
return 0;
}
То в консоли Windows будет:
OutOfMemory
Ok
, а в Ubuntu c Wine:
Ok
Ok
Выходит, что либо я что-то делаю не так, либо Wine в этом вопросе работает логичнее, чем Windows.
Заключение
Я очень надеюсь, что это я что-то не понял и поведение GDI+ является логичным. Правда, совсем не понятно зачем Microsoft всё сделала именно так. Я много копался в других их продуктах, и там тоже встречаются такие вещи, которые в приличном обществе точно бы не прошли Code Review.